diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index f060aa8ff..2c23ec43e 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -72,11 +72,13 @@ import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-a import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; +import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -101,7 +103,6 @@ import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; -import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; declare module "@fastify/request-context" { interface RequestContextData { @@ -220,6 +221,7 @@ declare module "fastify" { certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; + pkiSubscriber: TPkiSubscriberServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 13f3bc306..5b7c679c8 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -209,6 +209,9 @@ import { TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiSubscribers, + TPkiSubscribersInsert, + TPkiSubscribersUpdate, TProjectBots, TProjectBotsInsert, TProjectBotsUpdate, @@ -559,6 +562,11 @@ declare module "knex/types/tables" { TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate >; + [TableName.PkiSubscriber]: KnexOriginal.CompositeTableType< + TPkiSubscribers, + TPkiSubscribersInsert, + TPkiSubscribersUpdate + >; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20250504145538_pki-v2.ts b/backend/src/db/migrations/20250504145538_pki-v2.ts new file mode 100644 index 000000000..eca76643f --- /dev/null +++ b/backend/src/db/migrations/20250504145538_pki-v2.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiSubscriber))) { + await knex.schema.createTable(TableName.PkiSubscriber, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("name").notNullable(); + t.string("commonName").notNullable(); + t.specificType("subjectAlternativeNames", "text[]").notNullable(); + t.string("ttl").notNullable(); + t.specificType("keyUsages", "text[]").notNullable(); + t.specificType("extendedKeyUsages", "text[]").notNullable(); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.PkiSubscriber); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PkiSubscriber); + await dropOnUpdateTrigger(knex, TableName.PkiSubscriber); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b71d51908..ebbe417c4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -69,6 +69,7 @@ export * from "./organizations"; export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-subscribers"; export * from "./project-bots"; export * from "./project-environments"; export * from "./project-gateways"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 7fd77da6c..a07a15d4d 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -21,6 +21,7 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", diff --git a/backend/src/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts new file mode 100644 index 000000000..85f3d0d24 --- /dev/null +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiSubscribersSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + caId: z.string().uuid(), + name: z.string(), + commonName: z.string(), + subjectAlternativeNames: z.string().array(), + ttl: z.string(), + keyUsages: z.string().array(), + extendedKeyUsages: z.string().array() +}); + +export type TPkiSubscribers = z.infer; +export type TPkiSubscribersInsert = Omit, TImmutableDBKeys>; +export type TPkiSubscribersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 2403d6cf4..297601fd0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(true).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 93748c27f..4c749f6f5 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -73,7 +73,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const host = await server.services.sshHost.getSshHost({ + const host = await server.services.sshHost.getSshHostById({ sshHostId: req.params.sshHostId, actor: req.permission.type, actorId: req.permission.id, diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 1f4badfb5..3456ad81a 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -31,6 +31,7 @@ import { TUpdateSecretSyncDTO } from "@app/services/secret-sync/secret-sync-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; +import { CertKeyUsage, CertExtendedKeyUsage } from "@app/services/certificate/certificate-types"; import { KmipPermission } from "../kmip/kmip-enum"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; @@ -235,6 +236,10 @@ export enum EventType { GET_PKI_COLLECTION_ITEMS = "get-pki-collection-items", ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item", DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item", + CREATE_PKI_SUBSCRIBER = "create-pki-subscriber", + UPDATE_PKI_SUBSCRIBER = "update-pki-subscriber", + DELETE_PKI_SUBSCRIBER = "delete-pki-subscriber", + GET_PKI_SUBSCRIBER = "get-pki-subscriber", CREATE_KMS = "create-kms", UPDATE_KMS = "update-kms", DELETE_KMS = "delete-kms", @@ -1879,6 +1884,48 @@ interface DeletePkiCollectionItem { }; } +interface CreatePkiSubscriber { + type: EventType.CREATE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + caId: string; + name: string; + commonName: string; + ttl: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + }; +} + +interface UpdatePkiSubscriber { + type: EventType.UPDATE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + caId?: string; + name?: string; + commonName?: string; + ttl?: string; + subjectAlternativeNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + }; +} + +interface DeletePkiSubscriber { + type: EventType.DELETE_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + }; +} + +interface GetPkiSubscriber { + type: EventType.GET_PKI_SUBSCRIBER; + metadata: { + pkiSubscriberId: string; + }; +} + interface CreateKmsEvent { type: EventType.CREATE_KMS; metadata: { @@ -2835,6 +2882,10 @@ export type Event = | GetPkiCollectionItems | AddPkiCollectionItem | DeletePkiCollectionItem + | CreatePkiSubscriber + | UpdatePkiSubscriber + | DeletePkiSubscriber + | GetPkiSubscriber | CreateKmsEvent | UpdateKmsEvent | DeleteKmsEvent diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 319a0259a..977b66274 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -79,6 +79,15 @@ export enum ProjectPermissionSshHostActions { IssueHostCert = "issue-host-cert" } +export enum ProjectPermissionPkiSubscriberActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert", + SignCert = "sign-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -135,6 +144,7 @@ export enum ProjectPermissionSub { SshCertificateTemplates = "ssh-certificate-templates", SshHosts = "ssh-hosts", SshHostGroups = "ssh-host-groups", + PkiSubscribers = "pki-subscribers", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -241,6 +251,7 @@ export type ProjectPermissionSet = ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts | (ForcedSubject & SshHostSubjectFields) ] + | [ProjectPermissionPkiSubscriberActions, ProjectPermissionSub.PkiSubscribers] // (dangtony98): TODO: update | [ProjectPermissionActions, ProjectPermissionSub.SshHostGroups] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] @@ -719,6 +730,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SshHosts ); + can( + [ + ProjectPermissionPkiSubscriberActions.Read, + ProjectPermissionPkiSubscriberActions.Create, + ProjectPermissionPkiSubscriberActions.Edit, + ProjectPermissionPkiSubscriberActions.Delete, + ProjectPermissionPkiSubscriberActions.IssueCert + ], + ProjectPermissionSub.PkiSubscribers + ); + can( [ ProjectPermissionMemberActions.Create, @@ -977,6 +999,7 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + can([ProjectPermissionPkiSubscriberActions.Read], ProjectPermissionSub.PkiSubscribers); can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index 87f4862bb..e42d44ee1 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -324,7 +324,7 @@ export const sshHostServiceFactory = ({ return host; }; - const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { + const getSshHostById = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => { const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId); if (!host) { throw new NotFoundError({ @@ -616,7 +616,7 @@ export const sshHostServiceFactory = ({ createSshHost, updateSshHost, deleteSshHost, - getSshHost, + getSshHostById, issueSshHostUserCert, issueSshHostHostCert, getSshHostUserCaPk, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 10454ab9b..843d24d68 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -45,6 +45,7 @@ export enum ApiDocsTags { PkiCertificateTemplates = "PKI Certificate Templates", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", + PkiSubscribers = "PKI Subscribers", SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", @@ -595,6 +596,9 @@ export const PROJECTS = { commonName: "The common name of the certificate to filter by.", offset: "The offset to start from. If you enter 10, it will start from the 10th certificate.", limit: "The number of certificates to return." + }, + LIST_PKI_SUBSCRIBERS: { + projectId: "The ID of the project to list PKI subscribers for." } } as const; @@ -1686,6 +1690,46 @@ export const ALERTS = { } }; +export const PKI_SUBSCRIBERS = { + GET: { + subscriberId: "The ID of the PKI subscriber to get." + }, + CREATE: { + projectId: "The ID of the project to create the PKI subscriber in.", + caId: "The ID of the CA that will issue certificates for the PKI subscriber.", + name: "The name of the PKI subscriber.", + commonName: "The common name (CN) to be used on certificates issued for this subscriber.", + ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...", + subjectAlternativeNames: + "A list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.", + keyUsages: "The key usage extension to be used on certificates issued for this subscriber.", + extendedKeyUsages: "The extended key usage extension to be used on certificates issued for this subscriber." + }, + UPDATE: { + subscriberId: "The ID of the PKI subscriber to update.", + caId: "The ID of the CA that will issue certificates for the PKI subscriber to update to.", + name: "The name of the PKI subscriber to update to.", + commonName: "The common name (CN) to be used on certificates issued for this subscriber to update to.", + ttl: "The time to live for the certificates issued for this subscriber such as 1m, 1h, 1d, 1y, ...", + subjectAlternativeNames: + "A comma-delimited list of Subject Alternative Names (SANs) to be used on certificates issued for this subscriber; these can be host names or email addresses.", + keyUsages: "The key usage extension to be used on certificates issued for this subscriber to update to.", + extendedKeyUsages: + "The extended key usage extension to be used on certificates issued for this subscriber to update to." + }, + DELETE: { + subscriberId: "The ID of the PKI subscriber to delete." + }, + ISSUE_CERT: { + subscriberId: "The ID of the PKI subscriber to issue the certificate for.", + certificate: "The issued certificate.", + issuingCaCertificate: "The certificate of the issuing CA.", + certificateChain: "The certificate chain of the issued certificate.", + privateKey: "The private key of the issued certificate.", + serialNumber: "The serial number of the issued certificate." + } +}; + export const PKI_COLLECTIONS = { CREATE: { projectId: "The ID of the project to create the PKI collection in.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d15058bcb..158afc039 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -194,6 +194,8 @@ import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-servic import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; +import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -816,6 +818,7 @@ export const registerRoutes = async ( const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); + const pkiSubscriberDAL = pkiSubscriberDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -945,6 +948,11 @@ export const registerRoutes = async ( projectDAL }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ + pkiSubscriberDAL, + permissionService + }); + const projectTemplateService = projectTemplateServiceFactory({ licenseService, permissionService, @@ -1042,6 +1050,7 @@ export const registerRoutes = async ( projectRoleDAL, folderDAL, licenseService, + pkiSubscriberDAL, certificateAuthorityDAL, certificateDAL, pkiAlertDAL, @@ -1717,6 +1726,7 @@ export const registerRoutes = async ( certificateEst: certificateEstService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, + pkiSubscriber: pkiSubscriberService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index d2ba35a7e..4668bd3bd 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -26,11 +26,13 @@ import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiCollectionRouter } from "./pki-collection-router"; +import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; @@ -47,7 +49,6 @@ import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; import { registerWorkflowIntegrationRouter } from "./workflow-integration-router"; -import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); @@ -103,6 +104,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); + await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts new file mode 100644 index 000000000..fcd152381 --- /dev/null +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PKI_SUBSCRIBERS } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { validateAltNameField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema"; + +export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:subscriberId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Get PKI Subscriber", + params: z.object({ + subscriberId: z.string().describe(PKI_SUBSCRIBERS.GET.subscriberId) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.getPkiSubscriberById({ + subscriberId: req.params.subscriberId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.GET_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Create PKI Subscriber", + body: z.object({ + projectId: z.string().trim().describe(PKI_SUBSCRIBERS.CREATE.projectId), + caId: z + .string() + .trim() + .uuid("CA ID must be a valid UUID") + .min(1, "CA ID is required") + .describe(PKI_SUBSCRIBERS.CREATE.caId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.CREATE.name), + commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.CREATE.commonName), + ttl: z + .string() + .trim() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(PKI_SUBSCRIBERS.CREATE.ttl), + subjectAlternativeNames: validateAltNameField + .array() + .default([]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.subjectAlternativeNames), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .default([]) + .transform((arr) => Array.from(new Set(arr))) + .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.createPkiSubscriber({ + ...req.body, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.CREATE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + caId: subscriber.caId, + name: subscriber.name, + commonName: subscriber.commonName, + ttl: subscriber.ttl, + subjectAlternativeNames: subscriber.subjectAlternativeNames, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "PATCH", + url: "/:subscriberId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Update PKI Subscriber", + params: z.object({ + subscriberId: z.string().trim().describe(PKI_SUBSCRIBERS.UPDATE.subscriberId) + }), + body: z.object({ + caId: z + .string() + .trim() + .uuid("CA ID must be a valid UUID") + .min(1, "CA ID is required") + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.caId), + name: slugSchema({ min: 1, max: 64, field: "name" }).describe(PKI_SUBSCRIBERS.UPDATE.name).optional(), + commonName: z.string().trim().min(1).describe(PKI_SUBSCRIBERS.UPDATE.commonName).optional(), + subjectAlternativeNames: validateAltNameField + .array() + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.subjectAlternativeNames), + ttl: z + .string() + .trim() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.ttl), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .transform((arr) => Array.from(new Set(arr))) + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .transform((arr) => Array.from(new Set(arr))) + .optional() + .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.updatePkiSubscriber({ + subscriberId: req.params.subscriberId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.UPDATE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id, + caId: subscriber.caId, + name: subscriber.name, + commonName: subscriber.commonName, + ttl: subscriber.ttl, + subjectAlternativeNames: subscriber.subjectAlternativeNames, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "DELETE", + url: "/:subscriberId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Delete PKI Subscriber", + params: z.object({ + subscriberId: z.string().describe(PKI_SUBSCRIBERS.DELETE.subscriberId) + }), + response: { + 200: sanitizedPkiSubscriber + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscriber = await server.services.pkiSubscriber.deletePkiSubscriber({ + subscriberId: req.params.subscriberId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.DELETE_PKI_SUBSCRIBER, + metadata: { + pkiSubscriberId: subscriber.id + } + } + }); + + return subscriber; + } + }); + + server.route({ + method: "POST", + url: "/:subscriberId/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Issue certificate", + params: z.object({ + subscriberId: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + // TODO: reuse issueCertFromCa fn (or not since we are adding support for external CAs?) + // const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } = + // await server.services.pkiSubscriber.issuePkiSubscriberCertificate({ + // subscriberId: req.params.subscriberId, + // actor: req.permission.type, + // actorId: req.permission.id, + // actorAuthMethod: req.permission.authMethod, + // actorOrgId: req.permission.orgId + // }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_HOST_USER_CERT, + metadata: { + sshHostId: req.params.sshHostId, + hostname: host.hostname, + loginUser: req.body.loginUser, + principals, + ttl: host.userCertTtl + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm + }; + } + }); + + server.route({ + method: "POST", + url: "/:subscriberId/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiSubscribers], + description: "Sign certificate", + params: z.object({ + subscriberId: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberId) + }), + body: z.object({ + csr: z.string().trim().min(1). + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain), + serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + // TODO: reuse issueCertFromCa fn (or not since we are adding support for external CAs?) + const { serialNumber, signedPublicKey, privateKey, publicKey, keyAlgorithm, host, principals } = + await server.services.pkiSubscriber.issuePkiSubscriberCertificate({ + subscriberId: req.params.subscriberId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // orgId: req.permission.orgId, + // event: { + // type: EventType.ISSUE_SSH_HOST_USER_CERT, + // metadata: { + // sshHostId: req.params.sshHostId, + // hostname: host.hostname, + // loginUser: req.body.loginUser, + // principals, + // ttl: host.userCertTtl + // } + // } + // }); + + return { + serialNumber, + signedKey: signedPublicKey, + publicKey, + keyAlgorithm + }; + } + }); +}; diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a223004a9..3acf91cda 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -16,6 +16,7 @@ import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificat import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; +import { sanitizedPkiSubscriber } from "@app/services/pki-subscriber/pki-subscriber-schema"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; @@ -488,6 +489,36 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:projectId/pki-subscribers", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_PKI_SUBSCRIBERS.projectId) + }), + response: { + 200: z.object({ + subscribers: z.array(sanitizedPkiSubscriber) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const subscribers = await server.services.project.listProjectPkiSubscribers({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { subscribers }; + } + }); + server.route({ method: "GET", url: "/:projectId/certificate-templates", diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts index 979a3b9c5..4820cfe00 100644 --- a/backend/src/services/certificate-authority/certificate-authority-validators.ts +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -10,6 +10,18 @@ const isValidDate = (dateString: string) => { export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); +export const validateAltNameField = z + .string() + .trim() + .refine( + (name) => { + return isFQDN(name) || z.string().email().safeParse(name).success || isValidIp(name); + }, + { + message: "SAN must be a valid hostname, email address, or IP address" + } + ); + export const validateAltNamesField = z .string() .trim() diff --git a/backend/src/services/pki-subscriber/pki-subscriber-dal.ts b/backend/src/services/pki-subscriber/pki-subscriber-dal.ts new file mode 100644 index 000000000..355187a7d --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPkiSubscriberDALFactory = ReturnType; + +export const pkiSubscriberDALFactory = (db: TDbClient) => { + const pkiSubscriberOrm = ormify(db, TableName.PkiSubscriber); + + return { + ...pkiSubscriberOrm + }; +}; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts new file mode 100644 index 000000000..9fa428ff2 --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -0,0 +1,13 @@ +import { PkiSubscribersSchema } from "@app/db/schemas"; + +export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ + id: true, + projectId: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeNames: true, + ttl: true, + keyUsages: true, + extendedKeyUsages: true +}); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts new file mode 100644 index 000000000..2b389518b --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -0,0 +1,341 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionPkiSubscriberActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { getCaCredentials, keyAlgorithmToAlgCfg } from "@app/services/certificate-authority/certificate-authority-fns"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { + TCreatePkiSubscriberDTO, + TDeletePkiSubscriberDTO, + TGetPkiSubscriberByIdDTO, + TIssuePkiSubscriberCertDTO, + TSignPkiSubscriberCertDTO, + TUpdatePkiSubscriberDTO +} from "./pki-subscriber-types"; + +type TPkiSubscriberServiceFactoryDep = { + pkiSubscriberDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthorityCrlDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TPkiSubscriberServiceFactory = ReturnType; + +// TODO: bind subscribers to CA + +export const pkiSubscriberServiceFactory = ({ + pkiSubscriberDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService +}: TPkiSubscriberServiceFactoryDep) => { + const createPkiSubscriber = async ({ + name, + commonName, + caId, // (dangtony98) consider by CA name instead (newly-introduced field) + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreatePkiSubscriberDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Read, + ProjectPermissionSub.PkiSubscribers + ); + + const newSubscriber = await pkiSubscriberDAL.create({ + caId, + projectId, + name, + commonName, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages + }); + + return newSubscriber; + }; + + const getPkiSubscriberById = async ({ + subscriberId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetPkiSubscriberByIdDTO) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Read, + ProjectPermissionSub.PkiSubscribers + ); + + return subscriber; + }; + + const updatePkiSubscriber = async ({ + subscriberId, + name, + commonName, + caId, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdatePkiSubscriberDTO) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Edit, + ProjectPermissionSub.PkiSubscribers + ); + + const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriberId, { + caId, + name, + commonName, + ttl, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages + }); + + return updatedSubscriber; + }; + + const deletePkiSubscriber = async ({ + subscriberId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeletePkiSubscriberDTO) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: subscriber.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Delete, + ProjectPermissionSub.PkiSubscribers + ); + + await pkiSubscriberDAL.deleteById(subscriberId); + + return subscriber; + }; + + const issuePkiSubscriberCert = async ({ + subscriberId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssuePkiSubscriberCertDTO) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` }); + const ca = await certificateAuthorityDAL.findById(subscriber.caId); + if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.IssueCert, + ProjectPermissionSub.PkiSubscribers + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (ca.requireTemplateForIssuance) { + throw new BadRequestError({ message: "Certificate template is required for issuance" }); + } + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl)); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${subscriber.commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + }; + + const signPkiSubscriberCert = async ({ + subscriberId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TSignPkiSubscriberCertDTO) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber) throw new NotFoundError({ message: `PKI subscriber with ID '${subscriberId}' not found` }); + const ca = await certificateAuthorityDAL.findById(subscriber.caId); + if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + // (dangtony98): TODO: make permission more granular + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.SignCert, + ProjectPermissionSub.PkiSubscribers + ); + }; + + return { + createPkiSubscriber, + getPkiSubscriberById, + updatePkiSubscriber, + deletePkiSubscriber, + issuePkiSubscriberCert, + signPkiSubscriberCert + }; +}; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts new file mode 100644 index 000000000..3f5735d14 --- /dev/null +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -0,0 +1,40 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; + +export type TCreatePkiSubscriberDTO = { + caId: string; + name: string; + commonName: string; + ttl: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TGetPkiSubscriberByIdDTO = { + subscriberId: string; +} & Omit; + +export type TUpdatePkiSubscriberDTO = { + subscriberId: string; + caId?: string; + name?: string; + commonName?: string; + ttl?: string; + subjectAlternativeNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & Omit; + +export type TDeletePkiSubscriberDTO = { + subscriberId: string; +} & Omit; + +export type TIssuePkiSubscriberCertDTO = { + subscriberId: string; +} & Omit; + +export type TSignPkiSubscriberCertDTO = { + subscriberId: string; +} & Omit; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 8e60252ba..d7a0416ba 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -14,6 +14,7 @@ import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/servi import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, + ProjectPermissionPkiSubscriberActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -34,6 +35,7 @@ import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { TQueueServiceFactory } from "@app/queue"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; @@ -85,6 +87,7 @@ import { TListProjectCasDTO, TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, + TListProjectPkiSubscribersDTO, TListProjectsDTO, TListProjectSshCasDTO, TListProjectSshCertificatesDTO, @@ -144,6 +147,7 @@ type TProjectServiceFactoryDep = { "findById" | "findByIdWithWorkflowIntegrationDetails" >; projectUserMembershipRoleDAL: Pick; + pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; certificateTemplateDAL: Pick; @@ -206,6 +210,7 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + pkiSubscriberDAL, sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateDAL, @@ -1048,6 +1053,54 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of PKI subscribers for project + */ + const listProjectPkiSubscribers = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectPkiSubscribersDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const allowedSubscribers = []; + + // (dangtony98): room to optimize + const subscribers = await pkiSubscriberDAL.find({ projectId }); + + for (const subscriber of subscribers) { + try { + // (dangtony98): Add more granular permissions + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSubscriberActions.Read, + ProjectPermissionSub.PkiSubscribers + ); + + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionSshHostActions.Read, + // subject(ProjectPermissionSub.SshHosts, { + // hostname: host.hostname + // }) + // ); + + allowedSubscribers.push(subscriber); + } catch { + // intentionally ignore subscribers where user lacks access + } + } + + return allowedSubscribers; + }; + /** * Return list of certificate templates for project */ @@ -1921,6 +1974,7 @@ export const projectServiceFactory = ({ listProjectSshCas, listProjectSshHosts, listProjectSshHostGroups, + listProjectPkiSubscribers, listProjectSshCertificates, listProjectSshCertificateTemplates, updateVersionLimit, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index dc26d2357..9f74e123c 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -155,6 +155,7 @@ export type TListProjectCertificateTemplatesDTO = TProjectPermission; export type TListProjectSshCasDTO = TProjectPermission; export type TListProjectSshHostsDTO = TProjectPermission; export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectPkiSubscribersDTO = TProjectPermission; export type TListProjectSshCertificatesDTO = { offset: number; limit: number; diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index 5bc163817..b44213550 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -8,5 +8,6 @@ export { ProjectPermissionIdentityActions, ProjectPermissionKmipActions, ProjectPermissionMemberActions, + ProjectPermissionPkiSubscriberActions, ProjectPermissionSub } from "./types"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 71193dd6e..a88f8fe6d 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -87,6 +87,14 @@ export enum ProjectPermissionSshHostActions { IssueHostCert = "issue-host-cert" } +export enum ProjectPermissionPkiSubscriberActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -178,6 +186,7 @@ export enum ProjectPermissionSub { SshHostGroups = "ssh-host-groups", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", + PkiSubscribers = "pki-subscribers", Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", @@ -277,6 +286,7 @@ export type ProjectPermissionSet = | [ProjectPermissionSshHostActions, ProjectPermissionSub.SshHosts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] + | [ProjectPermissionPkiSubscriberActions, ProjectPermissionSub.PkiSubscribers] | [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 51f2797d0..e458f8ce1 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -16,6 +16,7 @@ export { ProjectPermissionIdentityActions, ProjectPermissionKmipActions, ProjectPermissionMemberActions, + ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, useProjectPermission } from "./ProjectPermissionContext"; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4bc06f7e3..4b4967f16 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -27,6 +27,7 @@ export * from "./orgAdmin"; export * from "./organization"; export * from "./pkiAlerts"; export * from "./pkiCollections"; +export * from "./pkiSubscriber"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; export * from "./roles"; diff --git a/frontend/src/hooks/api/pkiSubscriber/index.tsx b/frontend/src/hooks/api/pkiSubscriber/index.tsx new file mode 100644 index 000000000..b7bb58623 --- /dev/null +++ b/frontend/src/hooks/api/pkiSubscriber/index.tsx @@ -0,0 +1,6 @@ +export { + useCreatePkiSubscriber, + useDeletePkiSubscriber, + useUpdatePkiSubscriber +} from "./mutations"; +export { useGetPkiSubscriberById } from "./queries"; diff --git a/frontend/src/hooks/api/pkiSubscriber/mutations.tsx b/frontend/src/hooks/api/pkiSubscriber/mutations.tsx new file mode 100644 index 000000000..978009ae7 --- /dev/null +++ b/frontend/src/hooks/api/pkiSubscriber/mutations.tsx @@ -0,0 +1,61 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/query-keys"; +import { + TCreatePkiSubscriberDTO, + TDeletePkiSubscriberDTO, + TPkiSubscriber, + TUpdatePkiSubscriberDTO +} from "./types"; + +export const useCreatePkiSubscriber = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: subscriber } = await apiRequest.post("/api/v1/pki/subscribers", body); + return subscriber; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId) + }); + } + }); +}; + +export const useUpdatePkiSubscriber = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ subscriberId, ...body }) => { + const { data: subscriber } = await apiRequest.patch( + `/api/v1/pki/subscribers/${subscriberId}`, + body + ); + return subscriber; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId) + }); + } + }); +}; + +export const useDeletePkiSubscriber = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ subscriberId }) => { + const { data: subscriber } = await apiRequest.delete( + `/api/v1/pki/subscribers/${subscriberId}` + ); + return subscriber; + }, + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/pkiSubscriber/queries.tsx b/frontend/src/hooks/api/pkiSubscriber/queries.tsx new file mode 100644 index 000000000..ca244568e --- /dev/null +++ b/frontend/src/hooks/api/pkiSubscriber/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TPkiSubscriber } from "./types"; + +export const pkiSubscriberKeys = { + getPkiSubscriberById: (subscriberId: string) => [{ subscriberId }, "pki-subscriber"] as const +}; + +export const useGetPkiSubscriberById = (subscriberId: string) => { + return useQuery({ + queryKey: pkiSubscriberKeys.getPkiSubscriberById(subscriberId), + queryFn: async () => { + const { data: pkiSubscriber } = await apiRequest.get( + `/api/v1/pki/subscribers/${subscriberId}` + ); + return pkiSubscriber; + }, + enabled: Boolean(subscriberId) + }); +}; diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts new file mode 100644 index 000000000..c499721d4 --- /dev/null +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -0,0 +1,39 @@ +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificates/enums"; + +export type TPkiSubscriber = { + id: string; + projectId: string; + caId: string; + name: string; + commonName: string; + ttl: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +}; + +export type TCreatePkiSubscriberDTO = { + projectId: string; + caId: string; + name: string; + commonName: string; + ttl: string; + subjectAlternativeNames: string[]; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +}; + +export type TUpdatePkiSubscriberDTO = { + subscriberId: string; + caId?: string; + name?: string; + commonName?: string; + ttl?: string; + subjectAlternativeNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +}; + +export type TDeletePkiSubscriberDTO = { + subscriberId: string; +}; diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index e92ddeaa8..31d12a661 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -20,6 +20,7 @@ export type TSshHost = { hostCertTtl: string; loginMappings: TLoginMapping[]; }; + export type TCreateSshHostDTO = { projectId: string; hostname: string; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b841f4bff..df2d55dc3 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -35,6 +35,7 @@ export { useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, + useListWorkspacePkiSubscribers, useListWorkspaceSshCas, useListWorkspaceSshCertificates, useListWorkspaceSshCertificateTemplates, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 441b9aefa..278b62bc8 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -14,6 +14,7 @@ import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiCollection } from "../pkiCollections/types"; +import { TPkiSubscriber } from "../pkiSubscriber/types"; import { EncryptedSecret } from "../secrets/types"; import { TSshCertificate, TSshCertificateAuthority } from "../sshCa/types"; import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; @@ -874,6 +875,21 @@ export const useListWorkspaceSshHosts = (projectId: string) => { }); }; +export const useListWorkspacePkiSubscribers = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspacePkiSubscribers(projectId), + queryFn: async () => { + const { + data: { subscribers } + } = await apiRequest.get<{ subscribers: TPkiSubscriber[] }>( + `/api/v2/workspace/${projectId}/pki-subscribers` + ); + return subscribers; + }, + enabled: Boolean(projectId) + }); +}; + export const useListWorkspaceSshHostGroups = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSshHostGroups(projectId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 91fe95a04..22098235c 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -51,6 +51,8 @@ export const workspaceKeys = { }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const, getWorkspacePkiAlerts: (workspaceId: string) => [{ workspaceId }, "workspace-pki-alerts"] as const, + getWorkspacePkiSubscribers: (projectId: string) => + [{ projectId }, "workspace-pki-subscribers"] as const, getWorkspacePkiCollections: (workspaceId: string) => [{ workspaceId }, "workspace-pki-collections"] as const, getWorkspaceCertificateTemplates: (workspaceId: string) => diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index 8533d7007..fb14d0e93 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -103,6 +103,20 @@ export const ProjectLayout = () => { )} {isCertManager && ( <> + + {({ isActive }) => ( + + Subscribers + + )} + { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "PKI Subscribers" })} + +
+
+
+ + +
+
+
+ + ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx new file mode 100644 index 000000000..e48bff1cc --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -0,0 +1,435 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useCreatePkiSubscriber, + useGetPkiSubscriberById, + useListWorkspaceCas, + useListWorkspacePkiSubscribers, + useUpdatePkiSubscriber +} from "@app/hooks/api"; +import { + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["pkiSubscriber"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["pkiSubscriber"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + name: z.string().trim().min(1, "Name is required"), + caId: z.string().min(1, "Issuing CA is required"), + commonName: z.string().trim().min(1, "Common Name is required"), + subjectAlternativeNames: z.string(), + ttl: z.string().trim(), + keyUsages: z.object({ + [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), + [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), + [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), + [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), + [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), + [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() + }), + extendedKeyUsages: z.object({ + [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), + [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() + }) + }) + .required(); + +export type FormData = z.infer; + +export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { data: subscribers } = useListWorkspacePkiSubscribers(projectId); + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { data: pkiSubscriber } = useGetPkiSubscriberById( + (popUp?.pkiSubscriber?.data as { subscriberId: string })?.subscriberId || "" + ); + + const { mutateAsync: createMutateAsync } = useCreatePkiSubscriber(); + const { mutateAsync: updateMutateAsync } = useUpdatePkiSubscriber(); + + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + caId: "", + commonName: "", + subjectAlternativeNames: "", + ttl: "", + keyUsages: { + [CertKeyUsage.DIGITAL_SIGNATURE]: true, + [CertKeyUsage.KEY_ENCIPHERMENT]: true + }, + extendedKeyUsages: {} + } + }); + + useEffect(() => { + if (pkiSubscriber) { + reset({ + name: pkiSubscriber.name, + caId: pkiSubscriber.caId || "", + commonName: pkiSubscriber.commonName, + subjectAlternativeNames: pkiSubscriber.subjectAlternativeNames.join(", ") || "", + ttl: pkiSubscriber.ttl || "", + keyUsages: Object.fromEntries((pkiSubscriber.keyUsages || []).map((name) => [name, true])), + extendedKeyUsages: Object.fromEntries( + (pkiSubscriber.extendedKeyUsages || []).map((name) => [name, true]) + ) + }); + } else { + reset({ + name: "", + caId: "", + commonName: "", + subjectAlternativeNames: "", + ttl: "", + keyUsages: { + [CertKeyUsage.DIGITAL_SIGNATURE]: true, + [CertKeyUsage.KEY_ENCIPHERMENT]: true + }, + extendedKeyUsages: {} + }); + } + }, [pkiSubscriber, reset]); + + useEffect(() => { + if (cas?.length) { + setValue("caId", cas[0].id); + } + }, [cas, setValue]); + + const onFormSubmit = async ({ + name, + caId, + commonName, + subjectAlternativeNames, + ttl, + keyUsages, + extendedKeyUsages + }: FormData) => { + try { + if (!projectId) return; + + if (!caId) { + createNotification({ + text: "Please select an Issuing CA", + type: "error" + }); + return; + } + + console.log("onFormSubmitArgs: ", { + name, + caId, + commonName, + subjectAlternativeNames, + ttl, + keyUsages, + extendedKeyUsages + }); + + // Check if there is already a different subscriber with the same name + const existingNames = + subscribers?.filter((s) => s.id !== pkiSubscriber?.id).map((s) => s.name) || []; + + if (existingNames.includes(name.trim())) { + createNotification({ + text: "A subscriber with this name already exists.", + type: "error" + }); + return; + } + + const keyUsagesList = Object.entries(keyUsages) + .filter(([, value]) => value) + .map(([key]) => key as CertKeyUsage); + + const extendedKeyUsagesList = Object.entries(extendedKeyUsages) + .filter(([, value]) => value) + .map(([key]) => key as CertExtendedKeyUsage); + + const subjectAlternativeNamesList = subjectAlternativeNames + .split(",") + .map((san) => san.trim()) + .filter(Boolean); + + if (pkiSubscriber) { + await updateMutateAsync({ + subscriberId: pkiSubscriber.id, + name, + caId, + commonName, + subjectAlternativeNames: subjectAlternativeNamesList, + ttl, + keyUsages: keyUsagesList, + extendedKeyUsages: extendedKeyUsagesList + }); + } else { + await createMutateAsync({ + projectId, + name, + caId, + commonName, + subjectAlternativeNames: subjectAlternativeNamesList, + ttl, + keyUsages: keyUsagesList, + extendedKeyUsages: extendedKeyUsagesList + }); + } + + reset(); + handlePopUpToggle("pkiSubscriber", false); + + createNotification({ + text: `Successfully ${pkiSubscriber ? "updated" : "added"} PKI subscriber`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${pkiSubscriber ? "update" : "add"} PKI subscriber`, + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("pkiSubscriber", isOpen); + }} + > + +
+ {pkiSubscriber && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + + +
Key Usage
+
+ + { + return ( + +
+ {KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} +
+
+ ); + }} + /> + { + return ( + +
+ {EXTENDED_KEY_USAGES_OPTIONS.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} +
+
+ ); + }} + /> +
+
+
+
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx new file mode 100644 index 000000000..4d7c5f4ce --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -0,0 +1,93 @@ +import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub } from "@app/context"; +import { useDeletePkiSubscriber } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { PkiSubscriberModal } from "./PkiSubscriberModal"; +import { PkiSubscribersTable } from "./PkiSubscribersTable"; + +export const PkiSubscriberSection = () => { + const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "pkiSubscriber", + "deletePkiSubscriber" + ] as const); + + const onRemovePkiSubscriberSubmit = async (subscriberId: string) => { + try { + const subscriber = await deletePkiSubscriber({ subscriberId }); + + createNotification({ + text: `Successfully deleted PKI subscriber: ${subscriber.name}`, + type: "success" + }); + + handlePopUpClose("deletePkiSubscriber"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete PKI subscriber", + type: "error" + }); + } + }; + + return ( +
+
+

Subscribers

+
+ + + Documentation{" "} + + + + + {(isAllowed) => ( + + )} + +
+
+ + + handlePopUpToggle("deletePkiSubscriber", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemovePkiSubscriberSubmit( + (popUp?.deletePkiSubscriber?.data as { subscriberId: string })?.subscriberId + ) + } + /> +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx new file mode 100644 index 000000000..fae94b07b --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx @@ -0,0 +1,129 @@ +import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionPkiSubscriberActions, + ProjectPermissionSub, + useWorkspace +} from "@app/context"; +import { useListWorkspacePkiSubscribers } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deletePkiSubscriber", "pkiSubscriber"]>, + data?: object + ) => void; +}; + +export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isPending } = useListWorkspacePkiSubscribers(currentWorkspace?.id || ""); + return ( +
+ + + + + + + + + + {isPending && } + {!isPending && + data && + data.length > 0 && + data.map((subscriber) => { + return ( + + + + + + ); + })} + +
NameCommon Name +
{subscriber.name}{subscriber.commonName} + + +
+ + + +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("pkiSubscriber", { + subscriberId: subscriber.id + }); + }} + disabled={!isAllowed} + icon={} + > + Edit Subscriber + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deletePkiSubscriber", { + subscriberId: subscriber.id + }); + }} + disabled={!isAllowed} + icon={} + > + Delete Subscriber + + )} + + +
+
+ {!isPending && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/index.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/index.tsx new file mode 100644 index 000000000..4c9b89234 --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/index.tsx @@ -0,0 +1 @@ +export { PkiSubscriberSection } from "./PkiSubscriberSection"; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx new file mode 100644 index 000000000..3f14ebe1b --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { PkiSubscribersPage } from "./PkiSubscribersPage"; + +export const Route = createFileRoute( + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers" +)({ + component: PkiSubscribersPage +}); + +function RouteComponent() { + return ( +
+ Hello + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers"! +
+ ); +} diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 15d67ab46..55eaff495 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -91,6 +91,7 @@ import { Route as organizationSettingsPageOauthCallbackPageRouteImport } from '. import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/route' import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route' import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route' +import { Route as certManagerPkiSubscribersPageRouteImport } from './pages/cert-manager/PkiSubscribersPage/route' import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route' import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route' import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route' @@ -904,6 +905,13 @@ const kmsKmipPageRouteRoute = kmsKmipPageRouteImport.update({ getParentRoute: () => kmsLayoutRoute, } as any) +const certManagerPkiSubscribersPageRouteRoute = + certManagerPkiSubscribersPageRouteImport.update({ + id: '/subscribers', + path: '/subscribers', + getParentRoute: () => certManagerLayoutRoute, + } as any) + const certManagerSettingsPageRouteRoute = certManagerSettingsPageRouteImport.update({ id: '/settings', @@ -2184,6 +2192,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof certManagerSettingsPageRouteImport parentRoute: typeof certManagerLayoutImport } + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers': { + id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers' + path: '/subscribers' + fullPath: '/cert-manager/$projectId/subscribers' + preLoaderRoute: typeof certManagerPkiSubscribersPageRouteImport + parentRoute: typeof certManagerLayoutImport + } '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip': { id: '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip' path: '/kmip' @@ -3221,6 +3236,7 @@ interface certManagerLayoutRouteChildren { certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute + certManagerPkiSubscribersPageRouteRoute: typeof certManagerPkiSubscribersPageRouteRoute projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute certManagerCertAuthDetailsByIDPageRouteRoute: typeof certManagerCertAuthDetailsByIDPageRouteRoute projectIdentityDetailsByIDPageRouteCertManagerRoute: typeof projectIdentityDetailsByIDPageRouteCertManagerRoute @@ -3235,6 +3251,8 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = { certManagerCertificateAuthoritiesPageRouteRoute, certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute, certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute, + certManagerPkiSubscribersPageRouteRoute: + certManagerPkiSubscribersPageRouteRoute, projectAccessControlPageRouteCertManagerRoute: projectAccessControlPageRouteCertManagerRoute, certManagerCertAuthDetailsByIDPageRouteRoute: @@ -3905,6 +3923,7 @@ export interface FileRoutesByFullPath { '/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute '/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute '/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute + '/cert-manager/$projectId/subscribers': typeof certManagerPkiSubscribersPageRouteRoute '/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute '/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute '/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute @@ -4084,6 +4103,7 @@ export interface FileRoutesByTo { '/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute '/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute '/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute + '/cert-manager/$projectId/subscribers': typeof certManagerPkiSubscribersPageRouteRoute '/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute '/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute '/kms/$projectId/settings': typeof kmsSettingsPageRouteRoute @@ -4280,6 +4300,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview': typeof certManagerCertificatesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers': typeof certManagerPkiSubscribersPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/settings': typeof kmsSettingsPageRouteRoute @@ -4469,6 +4490,7 @@ export interface FileRouteTypes { | '/cert-manager/$projectId/certificate-authorities' | '/cert-manager/$projectId/overview' | '/cert-manager/$projectId/settings' + | '/cert-manager/$projectId/subscribers' | '/kms/$projectId/kmip' | '/kms/$projectId/overview' | '/kms/$projectId/settings' @@ -4647,6 +4669,7 @@ export interface FileRouteTypes { | '/cert-manager/$projectId/certificate-authorities' | '/cert-manager/$projectId/overview' | '/cert-manager/$projectId/settings' + | '/cert-manager/$projectId/subscribers' | '/kms/$projectId/kmip' | '/kms/$projectId/overview' | '/kms/$projectId/settings' @@ -4841,6 +4864,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings' + | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers' | '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip' | '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview' | '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/settings' @@ -5368,6 +5392,7 @@ export const routeTree = rootRoute "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings", + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/access-management", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/ca/$caId", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/identities/$identityId", @@ -5438,6 +5463,10 @@ export const routeTree = rootRoute "filePath": "cert-manager/SettingsPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" }, + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers": { + "filePath": "cert-manager/PkiSubscribersPage/route.tsx", + "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" + }, "/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip": { "filePath": "kms/KmipPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 3d9889ab3..d6a58e12c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -284,6 +284,7 @@ const secretManagerIntegrationsRedirect = route("/integrations", [ const certManagerRoutes = route("/cert-manager/$projectId", [ layout("cert-manager-layout", "cert-manager/layout.tsx", [ + route("/subscribers", "cert-manager/PkiSubscribersPage/route.tsx"), route("/overview", "cert-manager/CertificatesPage/route.tsx"), route("/certificate-authorities", "cert-manager/CertificateAuthoritiesPage/route.tsx"), route("/alerting", "cert-manager/AlertingPage/route.tsx"),