From edefa7698cd3ec48be54badf5be1a60107c317d0 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 17 May 2025 03:42:49 +0800 Subject: [PATCH] misc: addressed comments --- .../ee/services/audit-log/audit-log-types.ts | 19 +- .../certificate-authority-crl-service.ts | 4 +- .../certificate-est-service.ts | 4 +- .../certificate-authority-endpoints.ts | 120 ++++---- .../server/routes/v1/pki-subscriber-router.ts | 41 ++- .../acme/acme-certificate-authority-fns.ts | 124 ++++---- .../acme-certificate-authority-schemas.ts | 10 +- .../certificate-authority-fns.ts | 4 +- .../certificate-authority-queue.ts | 11 +- .../certificate-authority-service.ts | 57 ++-- .../certificate-authority-types.ts | 4 +- .../internal-certificate-authority-fns.ts | 3 +- .../internal-certificate-authority-service.ts | 43 +-- .../services/certificate/certificate-fns.ts | 8 +- .../certificate/certificate-service.ts | 266 ++++++++++++++++-- .../src/services/pki-alert/pki-alert-dal.ts | 8 +- .../pki-subscriber/pki-subscriber-service.ts | 4 + frontend/src/hooks/api/ca/index.tsx | 4 +- frontend/src/hooks/api/ca/mutations.tsx | 44 ++- frontend/src/hooks/api/ca/types.ts | 15 +- .../src/hooks/api/pkiSubscriber/queries.tsx | 29 +- .../components/ExternalCaModal.tsx | 114 ++++++-- .../components/ExternalCaSection.tsx | 34 ++- .../components/ExternalCaTable.tsx | 60 ++-- .../PkiSubscriberCertificatesTable.tsx | 17 +- 25 files changed, 744 insertions(+), 303 deletions(-) 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 175a7dd57..dcaaf1bdc 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -225,6 +225,7 @@ export enum EventType { REMOVE_HOST_FROM_SSH_HOST_GROUP = "remove-host-from-ssh-host-group", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", + GET_CAS = "get-certificate-authorities", UPDATE_CA = "update-certificate-authority", DELETE_CA = "delete-certificate-authority", RENEW_CA = "renew-certificate-authority", @@ -1718,7 +1719,7 @@ interface CreateCa { type: EventType.CREATE_CA; metadata: { caId: string; - dn: string; + dn?: string; }; } @@ -1726,7 +1727,14 @@ interface GetCa { type: EventType.GET_CA; metadata: { caId: string; - dn: string; + dn?: string; + }; +} + +interface GetCAs { + type: EventType.GET_CAS; + metadata: { + caIds: string[]; }; } @@ -1734,7 +1742,7 @@ interface UpdateCa { type: EventType.UPDATE_CA; metadata: { caId: string; - dn: string; + dn?: string; status: CaStatus; }; } @@ -1743,7 +1751,7 @@ interface DeleteCa { type: EventType.DELETE_CA; metadata: { caId: string; - dn: string; + dn?: string; }; } @@ -2031,7 +2039,7 @@ interface IssuePkiSubscriberCert { metadata: { subscriberId: string; name: string; - serialNumber: string; + serialNumber?: string; }; } @@ -2987,6 +2995,7 @@ export type Event = | IssueSshHostHostCert | CreateCa | GetCa + | GetCAs | UpdateCa | DeleteCa | RenewCa diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts index 0856c957c..844bda8ba 100644 --- a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -39,7 +39,7 @@ export const certificateAuthorityCrlServiceFactory = ({ if (!caCrl) throw new NotFoundError({ message: `CRL with ID '${crlId}' not found` }); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caCrl.caId); - if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${caCrl.caId}' not found` }); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caCrl.caId}' not found` }); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -67,7 +67,7 @@ export const certificateAuthorityCrlServiceFactory = ({ */ const getCaCrls = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCrlsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); - if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const { permission } = await permissionService.getProjectPermission({ actor, diff --git a/backend/src/ee/services/certificate-est/certificate-est-service.ts b/backend/src/ee/services/certificate-est/certificate-est-service.ts index 8039a977f..5dcd2b5e2 100644 --- a/backend/src/ee/services/certificate-est/certificate-est-service.ts +++ b/backend/src/ee/services/certificate-est/certificate-est-service.ts @@ -228,9 +228,9 @@ export const certificateEstServiceFactory = ({ } const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); - if (!ca?.internalCa) { + if (!ca?.internalCa?.id) { throw new NotFoundError({ - message: `Certificate Authority with ID '${certTemplate.caId}' not found` + message: `Internal Certificate Authority with ID '${certTemplate.caId}' not found` }); } diff --git a/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts index 6f1476039..d5907bf12 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts @@ -5,7 +5,7 @@ import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TCertificateAuthority, TCertificateAuthorityInput @@ -26,11 +26,13 @@ export const registerCertificateAuthorityEndpoints = < createSchema: z.ZodType<{ name: string; projectId: string; + status: CaStatus; configuration: I["configuration"]; disableDirectIssuance: boolean; }>; updateSchema: z.ZodType<{ name?: string; + status?: CaStatus; configuration?: I["configuration"]; disableDirectIssuance?: boolean; }>; @@ -63,18 +65,16 @@ export const registerCertificateAuthorityEndpoints = < req.permission )) as T[]; - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId, - // event: { - // type: EventType.GET_SECRET_SYNCS, - // metadata: { - // destination, - // count: secretSyncs.length, - // syncIds: secretSyncs.map((connection) => connection.id) - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_CAS, + metadata: { + caIds: certificateAuthorities.map((ca) => ca.id) + } + } + }); return { certificateAuthorities }; } @@ -105,17 +105,16 @@ export const registerCertificateAuthorityEndpoints = < req.permission )) as T; - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: secretSync.projectId, - // event: { - // type: EventType.GET_SECRET_SYNC, - // metadata: { - // syncId, - // destination - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: certificateAuthority.id + } + } + }); return { certificateAuthority }; } @@ -142,18 +141,16 @@ export const registerCertificateAuthorityEndpoints = < req.permission )) as T; - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: secretSync.projectId, - // event: { - // type: EventType.CREATE_SECRET_SYNC, - // metadata: { - // syncId: secretSync.id, - // destination, - // ...req.body - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + caId: certificateAuthority.id + } + } + }); return { certificateAuthority }; } @@ -181,22 +178,25 @@ export const registerCertificateAuthorityEndpoints = < const { certificateAuthorityId } = req.params; const certificateAuthority = (await server.services.certificateAuthority.updateCertificateAuthority( - { ...req.body, id: certificateAuthorityId, type: caType }, + { + ...req.body, + id: certificateAuthorityId, + type: caType + }, req.permission )) as T; - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: certificateAuthority.projectId, - // event: { - // type: EventType.UPDATE_SECRET_SYNC, - // metadata: { - // syncId, - // destination, - // ...req.body - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + caId: certificateAuthority.id, + status: certificateAuthority.status + } + } + }); return { certificateAuthority }; } @@ -227,18 +227,16 @@ export const registerCertificateAuthorityEndpoints = < req.permission )) as T; - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // orgId: req.permission.orgId, - // event: { - // type: EventType.DELETE_SECRET_SYNC, - // metadata: { - // destination, - // syncId, - // removeSecrets - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + caId: certificateAuthority.id + } + } + }); return { certificateAuthority }; } diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 127fc8404..93505c8b4 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -311,28 +311,27 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId }); - // await server.services.auditLog.createAuditLog({ - // ...req.auditLogInfo, - // projectId: subscriber.projectId, - // event: { - // type: EventType.ISSUE_PKI_SUBSCRIBER_CERT, - // metadata: { - // subscriberId: subscriber.id, - // name: subscriber.name, - // serialNumber - // } - // } - // }); + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: subscriber.projectId, + event: { + type: EventType.ISSUE_PKI_SUBSCRIBER_CERT, + metadata: { + subscriberId: subscriber.id, + name: subscriber.name + } + } + }); - // await server.services.telemetry.sendPostHogEvents({ - // event: PostHogEventTypes.IssueCert, - // distinctId: getTelemetryDistinctId(req), - // properties: { - // subscriberId: subscriber.id, - // commonName: subscriber.commonName, - // ...req.auditLogInfo - // } - // }); + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueCert, + distinctId: getTelemetryDistinctId(req), + properties: { + subscriberId: subscriber.id, + commonName: subscriber.commonName, + ...req.auditLogInfo + } + }); return { message: "Successfully placed order for certificate" diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index f78697e26..b30d7c573 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -29,7 +29,6 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; import { CaStatus, CaType } from "../certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; -import { TCertificateAuthority } from "../certificate-authority-types"; import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; import { AcmeDnsProvider } from "./acme-certificate-authority-enums"; import { AcmeCertificateAuthorityCredentialsSchema } from "./acme-certificate-authority-schemas"; @@ -62,12 +61,13 @@ type DBConfigurationColumn = { dnsProvider: string; directoryUrl: string; accountEmail: string; + hostedZoneId: string; }; export const castDbEntryToAcmeCertificateAuthority = ( ca: Awaited> ): TAcmeCertificateAuthority & { credentials: unknown } => { - if (!ca.externalCa) { + if (!ca.externalCa?.id) { throw new BadRequestError({ message: "Malformed ACME certificate authority" }); } @@ -82,7 +82,10 @@ export const castDbEntryToAcmeCertificateAuthority = ( credentials: ca.externalCa.credentials, configuration: { dnsAppConnectionId: ca.externalCa.dnsAppConnectionId as string, - dnsProvider: dbConfigurationCol.dnsProvider as AcmeDnsProvider, + dnsProviderConfig: { + provider: dbConfigurationCol.dnsProvider as AcmeDnsProvider, + hostedZoneId: dbConfigurationCol.hostedZoneId + }, directoryUrl: dbConfigurationCol.directoryUrl, accountEmail: dbConfigurationCol.accountEmail }, @@ -90,7 +93,12 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; -export const route53InsertTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => { +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global const route53Client = new Route53Client({ credentials: config.credentials!, @@ -98,7 +106,7 @@ export const route53InsertTxtRecord = async (connection: TAwsConnectionConfig, d }); const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: Get this from user input + HostedZoneId: hostedZoneId, ChangeBatch: { Comment: "Set ACME challenge TXT record", Changes: [ @@ -118,7 +126,12 @@ export const route53InsertTxtRecord = async (connection: TAwsConnectionConfig, d await route53Client.send(command); }; -export const route53DeleteTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => { +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global const route53Client = new Route53Client({ credentials: config.credentials!, @@ -126,7 +139,7 @@ export const route53DeleteTxtRecord = async (connection: TAwsConnectionConfig, d }); const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: same here + HostedZoneId: hostedZoneId, ChangeBatch: { Comment: "Delete ACME challenge TXT record", Changes: [ @@ -173,14 +186,14 @@ export const AcmeCertificateAuthorityFns = ({ disableDirectIssuance: boolean; actor: OrgServiceActor; }) => { - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProvider } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` }); } - if (dnsProvider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { + if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { throw new BadRequestError({ message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection` }); @@ -207,7 +220,8 @@ export const AcmeCertificateAuthorityFns = ({ configuration: { directoryUrl, accountEmail, - dnsProvider + dnsProvider: dnsProviderConfig.provider, + hostedZoneId: dnsProviderConfig.hostedZoneId }, status }, @@ -217,19 +231,11 @@ export const AcmeCertificateAuthorityFns = ({ return certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); }); - if (!caEntity.externalCa) { + if (!caEntity.externalCa?.id) { throw new BadRequestError({ message: "Failed to create external certificate authority" }); } - return { - id: caEntity.id, - type: CaType.ACME, - disableDirectIssuance: caEntity.disableDirectIssuance, - name: caEntity.externalCa.name, - projectId, - status, - configuration: caEntity.externalCa.configuration - } as TCertificateAuthority; + return castDbEntryToAcmeCertificateAuthority(caEntity); }; const updateCertificateAuthority = async ({ @@ -237,24 +243,26 @@ export const AcmeCertificateAuthorityFns = ({ status, configuration, disableDirectIssuance, - actor + actor, + name }: { id: string; status?: CaStatus; configuration: TUpdateAcmeCertificateAuthorityDTO["configuration"]; disableDirectIssuance?: boolean; actor: OrgServiceActor; + name?: string; }) => { const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { if (configuration) { - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProvider } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` }); } - if (dnsProvider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { + if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) { throw new BadRequestError({ message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection` }); @@ -273,24 +281,29 @@ export const AcmeCertificateAuthorityFns = ({ type: CaType.ACME }, { - configuration: { directoryUrl, accountEmail, dnsProvider, dnsAppConnectionId } + dnsAppConnectionId, + configuration: { + directoryUrl, + accountEmail, + dnsProvider: dnsProviderConfig.provider, + hostedZoneId: dnsProviderConfig.hostedZoneId + } }, tx ); } - if (status) { - await externalCertificateAuthorityDAL.update( - { - certificateAuthorityId: id, - type: CaType.ACME - }, - { - status - }, - tx - ); - } + await externalCertificateAuthorityDAL.update( + { + certificateAuthorityId: id, + type: CaType.ACME + }, + { + name, + status + }, + tx + ); if (disableDirectIssuance !== undefined) { await certificateAuthorityDAL.updateById( @@ -305,19 +318,11 @@ export const AcmeCertificateAuthorityFns = ({ return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); }); - if (!updatedCa.externalCa) { + if (!updatedCa.externalCa?.id) { throw new BadRequestError({ message: "Failed to update external certificate authority" }); } - return { - id: updatedCa.id, - type: CaType.ACME, - disableDirectIssuance: updatedCa.disableDirectIssuance, - name: updatedCa.externalCa.name, - projectId: updatedCa.projectId, - status: updatedCa.externalCa.status, - configuration: updatedCa.externalCa.configuration - }; + return castDbEntryToAcmeCertificateAuthority(updatedCa); }; const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { @@ -329,7 +334,7 @@ export const AcmeCertificateAuthorityFns = ({ return cas.map(castDbEntryToAcmeCertificateAuthority); }; - const orderCertificate = async (subscriberId: string) => { + const orderSubscriberCertificate = async (subscriberId: string) => { const subscriber = await pkiSubscriberDAL.findById(subscriberId); if (!subscriber.caId) { throw new BadRequestError({ message: "Subscriber does not have a CA" }); @@ -341,6 +346,9 @@ export const AcmeCertificateAuthorityFns = ({ } const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); + if (acmeCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -421,16 +429,26 @@ export const AcmeCertificateAuthorityFns = ({ const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) { - await route53InsertTxtRecord(connection as TAwsConnection, recordName, recordValue); + if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) { - await route53DeleteTxtRecord(connection as TAwsConnection, recordName, recordValue); + if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); } } }); @@ -466,7 +484,7 @@ export const AcmeCertificateAuthorityFns = ({ notAfter: certObj.notAfter, keyUsages: subscriber.keyUsages as CertKeyUsage[], extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], - caCertId: "s" // SHEEN TODO: merge Andrey's PR and then remove this + projectId: ca.projectId }, tx ); @@ -494,6 +512,6 @@ export const AcmeCertificateAuthorityFns = ({ createCertificateAuthority, updateCertificateAuthority, listCertificateAuthorities, - orderCertificate + orderSubscriberCertificate }; }; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts index 802edca76..0acc2d958 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts @@ -10,9 +10,13 @@ import { AcmeDnsProvider } from "./acme-certificate-authority-enums"; export const AcmeCertificateAuthorityConfigurationSchema = z.object({ dnsAppConnectionId: z.string().trim(), - dnsProvider: z.nativeEnum(AcmeDnsProvider), - directoryUrl: z.string().trim(), - accountEmail: z.string().trim() + // soon, differentiate via the provider property + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string().trim().min(1) + }), + directoryUrl: z.string().trim().min(1), + accountEmail: z.string().trim().min(1) }); export const AcmeCertificateAuthorityCredentialsSchema = z.object({ diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 75434a0ca..ac188bc4e 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -114,7 +114,7 @@ export const getCaCredentials = async ({ kmsService }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); - if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); if (!caSecret) throw new NotFoundError({ message: `CA secret for CA with ID '${caId}' not found` }); @@ -257,7 +257,7 @@ export const rebuildCaCrl = async ({ kmsService }: TRebuildCaCrlDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); - if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index 32246de73..54b451888 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -4,7 +4,7 @@ import crypto from "crypto"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -114,6 +114,11 @@ export const certificateAuthorityQueueFactory = ({ }; const orderCertificateForSubscriber = async ({ subscriberId, caType }: TOrderCertificateForSubscriberDTO) => { + const entry = await keyStore.getItem(KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId)); + if (entry) { + throw new BadRequestError({ message: `Certificate order already in progress for subscriber ${subscriberId}` }); + } + await queueService.queue( QueueName.CaLifecycle, QueueJobs.CaOrderCertificateForSubscriber, @@ -137,8 +142,6 @@ export const certificateAuthorityQueueFactory = ({ try { lock = await keyStore.acquireLock( [KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId)], - // scott: not sure on this duration; syncs can take excessive amounts of time so we need to keep it locked, - // but should always release below... 5 * 60 * 1000 ); } catch (e) { @@ -148,7 +151,7 @@ export const certificateAuthorityQueueFactory = ({ try { if (caType === CaType.ACME) { - await acmeFns.orderCertificate(subscriberId); + await acmeFns.orderSubscriberCertificate(subscriberId); } } catch (e) { logger.error(e, `CaOrderCertificate Failed [subscriberId=${subscriberId}] [job=${job.name}]`); diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 8d0894ede..b4000c678 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -14,7 +14,10 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TKmsServiceFactory } from "../kms/kms-service"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { TProjectDALFactory } from "../project/project-dal"; -import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { + AcmeCertificateAuthorityFns, + castDbEntryToAcmeCertificateAuthority +} from "./acme/acme-certificate-authority-fns"; import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO @@ -153,6 +156,8 @@ export const certificateAuthorityServiceFactory = ({ actor }); } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; const findCertificateAuthorityById = async ( @@ -181,9 +186,9 @@ export const certificateAuthorityServiceFactory = ({ ); if (type === CaType.INTERNAL) { - if (!certificateAuthority.internalCa) { + if (!certificateAuthority.internalCa?.id) { throw new NotFoundError({ - message: `Could not find internal certificate authority with ID "${certificateAuthorityId}"` + message: `Internal certificate authority with ID "${certificateAuthorityId}" not found` }); } @@ -204,14 +209,11 @@ export const certificateAuthorityServiceFactory = ({ }); } - return { - id: certificateAuthority.id, - type, - disableDirectIssuance: certificateAuthority.disableDirectIssuance, - name: certificateAuthority.externalCa.name, - projectId: certificateAuthority.projectId, - configuration: certificateAuthority.externalCa.configuration - } as TCertificateAuthority; + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; const listCertificateAuthoritiesByProjectId = async ( @@ -264,10 +266,12 @@ export const certificateAuthorityServiceFactory = ({ if (type === CaType.ACME) { return acmeFns.listCertificateAuthorities({ projectId: finalProjectId }); } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; const updateCertificateAuthority = async ( - { id, type, configuration, disableDirectIssuance, status }: TUpdateCertificateAuthorityDTO, + { id, type, configuration, disableDirectIssuance, status, name }: TUpdateCertificateAuthorityDTO, actor: OrgServiceActor ) => { const certificateAuthority = await certificateAuthorityDAL.findByIdWithAssociatedCa(id); @@ -292,9 +296,9 @@ export const certificateAuthorityServiceFactory = ({ ); if (type === CaType.INTERNAL) { - if (!certificateAuthority.internalCa) { + if (!certificateAuthority.internalCa?.id) { throw new NotFoundError({ - message: `Could not find internal certificate authority with ID "${id}"` + message: `Internal certificate authority with ID "${id}" not found` }); } @@ -328,7 +332,8 @@ export const certificateAuthorityServiceFactory = ({ configuration: configuration as TUpdateAcmeCertificateAuthorityDTO["configuration"], disableDirectIssuance, actor, - status + status, + name }); } @@ -357,15 +362,15 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - if (!certificateAuthority.internalCa && type === CaType.INTERNAL) { + if (!certificateAuthority.internalCa?.id && type === CaType.INTERNAL) { throw new BadRequestError({ - message: "Certificate authority cannot be deleted due to mismatching type" + message: "Internal certificate authority cannot be deleted" }); } - if (certificateAuthority.externalCa && certificateAuthority.externalCa.type !== type) { + if (certificateAuthority.externalCa?.id && certificateAuthority.externalCa.type !== type) { throw new BadRequestError({ - message: "Certificate authority cannot be deleted due to mismatching type" + message: "External certificate authority cannot be deleted" }); } @@ -383,15 +388,11 @@ export const certificateAuthorityServiceFactory = ({ } as TCertificateAuthority; } - return { - id: certificateAuthority.id, - type, - disableDirectIssuance: certificateAuthority.disableDirectIssuance, - name: certificateAuthority.externalCa?.name, - projectId: certificateAuthority.projectId, - configuration: certificateAuthority.externalCa?.configuration, - status: certificateAuthority.externalCa?.status - } as TCertificateAuthority; + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; return { diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 68e22aef7..5f1949887 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -9,9 +9,7 @@ export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertifi export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput; -export type TCreateCertificateAuthorityDTO = Omit & { - type: CaType; -}; +export type TCreateCertificateAuthorityDTO = Omit; export type TUpdateCertificateAuthorityDTO = Partial> & { type: CaType; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index ffe9fe4e0..77514f096 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -225,7 +225,8 @@ export const InternalCertificateAuthorityFns = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId }, tx ); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index d19cdc484..d165d4d14 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1209,8 +1209,8 @@ export const internalCertificateAuthorityServiceFactory = ({ ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); } - if (!ca?.internalCa) { - throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); } const { permission } = await permissionService.getProjectPermission({ @@ -1475,7 +1475,8 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca.projectId }, tx ); @@ -1560,8 +1561,8 @@ export const internalCertificateAuthorityServiceFactory = ({ ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); } - if (!ca?.internalCa) { - throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); } if (!dto.isInternal) { @@ -1854,6 +1855,20 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(new Uint8Array(leafCert.rawData)) }); + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + await certificateDAL.transaction(async (tx) => { const cert = await certificateDAL.create( { @@ -1868,7 +1883,8 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca.projectId }, tx ); @@ -1876,7 +1892,8 @@ export const internalCertificateAuthorityServiceFactory = ({ await certificateBodyDAL.create( { certId: cert.id, - encryptedCertificate + encryptedCertificate, + encryptedCertificateChain }, tx ); @@ -1894,17 +1911,9 @@ export const internalCertificateAuthorityServiceFactory = ({ return cert; }); - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.internalCa.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - return { certificate: leafCert, - certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + certificateChain: certificateChainPem, issuingCaCertificate, serialNumber, ca: expandInternalCa(ca), @@ -1923,7 +1932,7 @@ export const internalCertificateAuthorityServiceFactory = ({ actorOrgId }: TGetCaCertificateTemplatesDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); - if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); const { permission } = await permissionService.getProjectPermission({ actor, diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 9fe77de21..ffdaec3b4 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -52,8 +53,11 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) .join("\n") .trim(); -export const splitPemChain = (pemText: string) => - pemText.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g) || []; +export const splitPemChain = (pemText: string) => { + const re2Pattern = new RE2("-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----", "g"); + + return re2Pattern.match(pemText) || []; +}; /** * Return the public and private key of certificate diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 3ce80e635..d0dd620ac 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -22,7 +22,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; -import { buildCertificateChain, getCertificateCredentials, revocationReasonToCrlCode } from "./certificate-fns"; +import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { CertExtendedKeyUsage, @@ -39,9 +39,9 @@ import { } from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; - certificateSecretDAL: Pick; - certificateBodyDAL: Pick; + certificateDAL: Pick; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; @@ -77,7 +77,6 @@ export const certificateServiceFactory = ({ */ const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, @@ -94,8 +93,7 @@ export const certificateServiceFactory = ({ ); return { - cert, - ca: expandInternalCa(ca) + cert }; }; @@ -110,7 +108,6 @@ export const certificateServiceFactory = ({ actorOrgId }: TGetCertPrivateKeyDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, @@ -135,7 +132,6 @@ export const certificateServiceFactory = ({ }); return { - ca: expandInternalCa(ca), cert, certPrivateKey }; @@ -146,7 +142,6 @@ export const certificateServiceFactory = ({ */ const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, @@ -165,8 +160,7 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); return { - deletedCert, - ca: expandInternalCa(ca) + deletedCert }; }; @@ -184,8 +178,21 @@ export const certificateServiceFactory = ({ actorOrgId }: TRevokeCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); + + if (!cert.caId) { + throw new BadRequestError({ + message: "Cannot revoke imported certificates" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); + if (ca.externalCa?.id) { + throw new BadRequestError({ + message: "Cannot revoke external certificates" + }); + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -234,7 +241,6 @@ export const certificateServiceFactory = ({ */ const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, @@ -292,8 +298,234 @@ export const certificateServiceFactory = ({ certificate: certObj.toString("pem"), certificateChain, serialNumber: certObj.serialNumber, - cert, - ca: expandInternalCa(ca) + cert + }; + }; + + /** + * Import certificate + */ + const importCert = async ({ + projectSlug, + pkiCollectionId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + friendlyName, + certificatePem, + chainPem, + privateKeyPem + }: TImportCertDTO) => { + const collectionId = pkiCollectionId; + + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + // Check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + const leafCert = new x509.X509Certificate(certificatePem); + + // Verify the certificate chain + const chainCerts = splitPemChain(chainPem).map((pem) => new x509.X509Certificate(pem)); + + // Remove leaf cert from the chain if it's present + if (chainCerts[0].equal(leafCert)) { + chainCerts.splice(0, 1); + } + + if (chainCerts.length === 0) { + throw new BadRequestError({ + message: "Certificate chain must contain at least one issuer certificate" + }); + } + + // Verify leaf certificate is signed by the first certificate in the chain + const isLeafVerified = await leafCert.verify({ publicKey: chainCerts[0].publicKey }).catch(() => false); + if (!isLeafVerified) { + throw new BadRequestError({ message: "Leaf certificate verification against chain failed" }); + } + + // Verify the entire chain of trust + const verificationPromises = chainCerts.slice(0, -1).map(async (currentCert, index) => { + const issuerCert = chainCerts[index + 1]; + return currentCert.verify({ publicKey: issuerCert.publicKey }).catch(() => false); + }); + + const verificationResults = await Promise.all(verificationPromises); + + if (verificationResults.some((result) => !result)) { + throw new BadRequestError({ + message: "Certificate chain verification failed: broken trust chain" + }); + } + + // Verify private key matches the certificate + let privateKey; + try { + privateKey = createPrivateKey(privateKeyPem); + } catch (err) { + throw new BadRequestError({ message: "Invalid private key format" }); + } + + try { + const message = Buffer.from(Buffer.alloc(32)); + const publicKey = createPublicKey(certificatePem); + const signature = sign(null, message, privateKey); + const isValid = verify(null, message, publicKey, signature); + + if (!isValid) { + throw new BadRequestError({ message: "Private key does not match certificate" }); + } + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + throw new BadRequestError({ message: "Error verifying private key against certificate" }); + } + + // Get certificate attributes + const commonName = Array.from(leafCert.subjectName.getField("CN")?.values() || [])[0] || ""; + + let altNames: undefined | string; + const sanExtension = leafCert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + altNames = sanNames.items.map((name) => name.value).join(", "); + } + + const { serialNumber, notBefore, notAfter } = leafCert; + + // Encrypt certificate for storage + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certificatePem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(privateKeyPem) + }); + + // Extract Key Usage + const keyUsagesExt = leafCert.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + + let keyUsages: CertKeyUsage[] = []; + if (keyUsagesExt) { + keyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & keyUsagesExt.usages) !== 0 + ); + } + + // Extract Extended Key Usage + const extKeyUsageExt = leafCert.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + let extendedKeyUsages: CertExtendedKeyUsage[] = []; + if (extKeyUsageExt) { + extendedKeyUsages = extKeyUsageExt.usages.map((ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]); + } + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(chainPem) + }); + + const cert = await certificateDAL.transaction(async (tx) => { + try { + const txCert = await certificateDAL.create( + { + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore, + notAfter, + projectId, + keyUsages, + extendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: txCert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: txCert.id, + encryptedPrivateKey + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: txCert.id + }, + tx + ); + } + + return txCert; + } catch (error) { + // @ts-expect-error We're expecting a database error + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (error?.error?.code === "23505") { + throw new BadRequestError({ message: "Certificate serial already exists in your project" }); + } + throw error; + } + }); + + return { + certificate: certificatePem, + certificateChain: chainPem, + privateKey: privateKeyPem, + serialNumber, + cert }; }; @@ -303,7 +535,6 @@ export const certificateServiceFactory = ({ */ const getCertBundle = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBundleDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); const { permission } = await permissionService.getProjectPermission({ actor, @@ -375,8 +606,7 @@ export const certificateServiceFactory = ({ certificateChain, privateKey: certPrivateKey, serialNumber, - cert, - ca: expandInternalCa(ca) + cert }; }; diff --git a/backend/src/services/pki-alert/pki-alert-dal.ts b/backend/src/services/pki-alert/pki-alert-dal.ts index eddce1570..2f560b17f 100644 --- a/backend/src/services/pki-alert/pki-alert-dal.ts +++ b/backend/src/services/pki-alert/pki-alert-dal.ts @@ -25,19 +25,19 @@ export const pkiAlertDALFactory = (db: TDbClient) => { recipientEmails: string; }; - // SHEEN TODO: FIX REGRESION HERE // gets CAs and certificates as part of PKI collection items const combinedQuery = db .replicaNode() .select( db.raw("? as type", [PkiItemType.CA]), `${PkiItemType.CA}.id`, - `${PkiItemType.CA}.notAfter as expiryDate`, - `${PkiItemType.CA}.serialNumber`, - `${PkiItemType.CA}.friendlyName`, + "ic.notAfter as expiryDate", + "ic.serialNumber", + "ic.friendlyName", "pci.pkiCollectionId" ) .from(`${TableName.CertificateAuthority} as ${PkiItemType.CA}`) + .join(`${TableName.InternalCertificateAuthority} as ic`, `${PkiItemType.CA}.id`, "ic.certificateAuthorityId") .join(`${TableName.PkiCollectionItem} as pci`, `${PkiItemType.CA}.id`, "pci.caId") .unionAll((qb) => { void qb diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 793abeaa3..a08f0f360 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -320,6 +320,10 @@ export const pkiSubscriberServiceFactory = ({ throw new BadRequestError({ message: "CA does not support ordering of certificates" }); } + if (ca.externalCa?.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { await certificateAuthorityQueue.orderCertificateForSubscriber({ subscriberId: subscriber.id, diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index c3ad868cd..bf3a21265 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -4,10 +4,12 @@ export { useCreateCertificate, useCreateUnifiedCa, useDeleteCa, + useDeleteUnifiedCa, useImportCaCertificate, useRenewCa, useSignIntermediate, - useUpdateCa + useUpdateCa, + useUpdateUnifiedCa } from "./mutations"; export { useGetCaById, diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 7f8b0fdff..70fe1f4cd 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -11,6 +11,7 @@ import { TCreateCertificateResponse, TCreateUnifiedCertificateAuthorityDTO, TDeleteCaDTO, + TDeleteUnifiedCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, TRenewCaDTO, @@ -18,9 +19,31 @@ import { TSignIntermediateDTO, TSignIntermediateResponse, TUnifiedCertificateAuthority, - TUpdateCaDTO + TUpdateCaDTO, + TUpdateUnifiedCertificateAuthorityDTO } from "./types"; +export const useUpdateUnifiedCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...body }) => { + const { + data: { certificateAuthority } + } = await apiRequest.patch<{ certificateAuthority: TUnifiedCertificateAuthority }>( + `/api/v1/pki/ca/${body.type}/${id}`, + body + ); + + return certificateAuthority; + }, + onSuccess: ({ projectId, type }) => { + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) + }); + } + }); +}; + export const useCreateUnifiedCa = () => { const queryClient = useQueryClient(); return useMutation({ @@ -39,6 +62,25 @@ export const useCreateUnifiedCa = () => { }); }; +export const useDeleteUnifiedCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, type }) => { + const { + data: { certificateAuthority } + } = await apiRequest.delete<{ certificateAuthority: TUnifiedCertificateAuthority }>( + `/api/v1/pki/ca/${type}/${caId}` + ); + return certificateAuthority; + }, + onSuccess: (_, { type, projectId }) => { + queryClient.invalidateQueries({ + queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) + }); + } + }); +}; + export const useCreateCa = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 014c75018..34eade1f4 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -10,7 +10,10 @@ export type TAcmeCertificateAuthority = { disableDirectIssuance: boolean; configuration: { dnsAppConnectionId: string; - dnsProvider: AcmeDnsProvider; + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53; + hostedZoneId: string; + }; directoryUrl: string; accountEmail: string; }; @@ -48,6 +51,16 @@ export type TUnifiedCertificateAuthority = | TInternalCertificateAuthority; export type TCreateUnifiedCertificateAuthorityDTO = Omit; +export type TUpdateUnifiedCertificateAuthorityDTO = Partial & { + id: string; + type: CaType; +}; + +export type TDeleteUnifiedCertificateAuthorityDTO = { + caId: string; + type: CaType; + projectId: string; +}; export type TCertificateAuthority = { id: string; diff --git a/frontend/src/hooks/api/pkiSubscriber/queries.tsx b/frontend/src/hooks/api/pkiSubscriber/queries.tsx index d9948ed5c..69e0e78d9 100644 --- a/frontend/src/hooks/api/pkiSubscriber/queries.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/queries.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TReactQueryOptions } from "@app/types/reactQuery"; import { TCertificate } from "../certificates/types"; import { TPkiSubscriber } from "./types"; @@ -62,17 +63,20 @@ export const useGetPkiSubscriber = ({ }); }; -export const useGetPkiSubscriberCertificates = ({ - subscriberName, - projectId, - offset, - limit -}: { - subscriberName: string; - projectId: string; - offset: number; - limit: number; -}) => { +export const useGetPkiSubscriberCertificates = ( + { + subscriberName, + projectId, + offset, + limit + }: { + subscriberName: string; + projectId: string; + offset: number; + limit: number; + }, + options?: TReactQueryOptions["options"] +) => { return useQuery({ queryKey: pkiSubscriberKeys.specificPkiSubscriberCertificates({ subscriberName, @@ -97,6 +101,7 @@ export const useGetPkiSubscriberCertificates = ({ ); return { certificates, totalCount }; }, - enabled: Boolean(subscriberName) && Boolean(projectId) + enabled: Boolean(subscriberName) && Boolean(projectId), + ...options }); }; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 9acbf6d38..608a2c403 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -23,23 +23,27 @@ import { CaStatus, CaType, useCreateUnifiedCa, - useGetCaById, - useUpdateCa + useGetCaByTypeAndId, + useUpdateUnifiedCa } from "@app/hooks/api/ca"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z .object({ - type: z.enum([CaType.ACME]), + type: z.nativeEnum(CaType), name: z.string(), disableDirectIssuance: z.boolean(), - status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]), + status: z.nativeEnum(CaStatus), configuration: z.object({ dnsAppConnection: z.object({ id: z.string(), name: z.string() }), - dnsProvider: z.nativeEnum(AcmeDnsProvider), + // currently specific to Route53 but can be extended to others by differentiating via the provider property + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), directoryUrl: z.string(), accountEmail: z.string() }) @@ -58,11 +62,13 @@ const caTypes = [{ label: "ACME", value: CaType.ACME }]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); + const { data: ca } = useGetCaByTypeAndId( + (popUp?.ca?.data as { type: CaType })?.type || "", + (popUp?.ca?.data as { caId: string })?.caId || "" + ); - // SHEEN TODO: finish up CA management const { mutateAsync: createMutateAsync } = useCreateUnifiedCa(); - const { mutateAsync: updateMutateAsync } = useUpdateCa(); + const { mutateAsync: updateMutateAsync } = useUpdateUnifiedCa(); const { control, @@ -82,7 +88,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { id: "", name: "" }, - dnsProvider: AcmeDnsProvider.ROUTE53, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, directoryUrl: "", accountEmail: "" } @@ -90,7 +99,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { }); const caType = watch("type"); - const dnsProvider = watch("configuration.dnsProvider"); + const dnsProvider = watch("configuration.dnsProviderConfig.provider"); const { data: availableConnections, isPending } = useListAvailableAppConnections( AppConnection.AWS, @@ -101,11 +110,30 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { useEffect(() => { if (ca) { - // reset({ - // type: ca.type, - // name: ca.name, - // disableDirectIssuance: ca.disableDirectIssuance - // }); + if (ca.type !== CaType.INTERNAL && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca?.configuration.dnsAppConnectionId + ); + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + disableDirectIssuance: ca.disableDirectIssuance, + configuration: { + dnsAppConnection: { + id: ca.configuration.dnsAppConnectionId, + name: selectedConnection?.name || "" + }, + dnsProviderConfig: { + provider: ca.configuration.dnsProviderConfig.provider, + hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId + }, + directoryUrl: ca.configuration.directoryUrl, + accountEmail: ca.configuration.accountEmail + } + }); + } } else { reset({ type: CaType.ACME, @@ -117,13 +145,16 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { id: "", name: "" }, - dnsProvider: AcmeDnsProvider.ROUTE53, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, directoryUrl: "", accountEmail: "" } }); } - }, [ca]); + }, [ca, availableConnections]); const onFormSubmit = async ({ type, @@ -135,17 +166,20 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!currentWorkspace?.slug) return; - if (ca) { - // update - // await updateMutateAsync({ - // projectSlug: currentWorkspace.slug, - // caId: ca.id, - // name, - // disableDirectIssuance, - // status - // }); + if (ca && type !== CaType.INTERNAL) { + await updateMutateAsync({ + id: ca.id, + projectId: currentWorkspace.id, + name, + type, + status, + disableDirectIssuance, + configuration: { + ...configuration, + dnsAppConnectionId: configuration.dnsAppConnection.id + } + }); } else { - // create await createMutateAsync({ projectId: currentWorkspace.id, name, @@ -217,7 +251,12 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { defaultValue="" name="name" render={({ field, fieldState: { error } }) => ( - + )} @@ -226,7 +265,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { <> ( { control={control} name="configuration.dnsAppConnection" /> + ( + + + + )} + /> { label="Directory URL" isError={Boolean(error)} errorText={error?.message} + isRequired > { label="Account Email" isError={Boolean(error)} errorText={error?.message} + isRequired > diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx index e4beec0b8..bfdc22208 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx @@ -6,7 +6,7 @@ import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { CaStatus, CaType, useDeleteUnifiedCa, useUpdateUnifiedCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { ExternalCaModal } from "./ExternalCaModal"; @@ -14,8 +14,8 @@ import { ExternalCaTable } from "./ExternalCaTable"; export const ExternalCaSection = () => { const { currentWorkspace } = useWorkspace(); - const { mutateAsync: deleteCa } = useDeleteCa(); - const { mutateAsync: updateCa } = useUpdateCa(); + const { mutateAsync: deleteCa } = useDeleteUnifiedCa(); + const { mutateAsync: updateCa } = useUpdateUnifiedCa(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "ca", @@ -24,11 +24,11 @@ export const ExternalCaSection = () => { "upgradePlan" ] as const); - const onRemoveCaSubmit = async (caId: string) => { + const onRemoveCaSubmit = async (caId: string, type: CaType) => { try { if (!currentWorkspace?.slug) return; - await deleteCa({ caId, projectSlug: currentWorkspace.slug }); + await deleteCa({ caId, type, projectId: currentWorkspace.id }); createNotification({ text: "Successfully deleted CA", @@ -44,11 +44,19 @@ export const ExternalCaSection = () => { } }; - const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + const onUpdateCaStatus = async ({ + caId, + type, + status + }: { + caId: string; + type: CaType; + status: CaStatus; + }) => { try { if (!currentWorkspace?.slug) return; - await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); + await updateCa({ id: caId, type, status }); createNotification({ text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, @@ -96,7 +104,12 @@ export const ExternalCaSection = () => { subTitle="This action will delete other CAs and certificates below it in your CA hierarchy." onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)} deleteKey="confirm" - onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + onDeleteApproved={() => + onRemoveCaSubmit( + (popUp?.deleteCa?.data as { caId: string })?.caId, + (popUp?.deleteCa?.data as { type: CaType })?.type + ) + } /> { : "This action will prevent the CA from issuing new certificates." } onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)} + buttonText="Proceed" deleteKey="confirm" onDeleteApproved={() => - onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) + onUpdateCaStatus( + popUp?.caStatus?.data as { caId: string; type: CaType; status: CaStatus } + ) } /> , data?: { caId?: string; - dn?: string; + type?: CaType; status?: CaStatus; description?: string; } @@ -40,7 +44,6 @@ type Props = { }; export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { - const navigate = useNavigate(); const { currentWorkspace } = useWorkspace(); const { data, isPending } = useListCasByTypeAndProjectId(CaType.ACME, currentWorkspace.id); @@ -66,15 +69,12 @@ export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { - navigate({ - to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const, - params: { - projectId: currentWorkspace.id, - caId: ca.id - } - }) - } + onClick={() => { + handlePopUpOpen("ca", { + caId: ca.id, + type: ca.type + }); + }} > {ca.name} {ca.type} @@ -93,6 +93,29 @@ export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("ca", { + caId: ca.id, + type: ca.type + }); + }} + disabled={!isAllowed} + icon={} + > + Edit CA + + )} + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( { e.stopPropagation(); handlePopUpOpen("caStatus", { caId: ca.id, + type: ca.type, status: ca.status === CaStatus.ACTIVE ? CaStatus.DISABLED @@ -133,10 +157,10 @@ export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { )} onClick={(e) => { e.stopPropagation(); - // handlePopUpOpen("deleteCa", { - // caId: ca.id, - // dn: ca.dn - // }); + handlePopUpOpen("deleteCa", { + caId: ca.id, + type: ca.type + }); }} disabled={!isAllowed} icon={} diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx index e6e49b758..e0ba984e6 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx @@ -48,12 +48,17 @@ export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); - const { data, isPending } = useGetPkiSubscriberCertificates({ - subscriberName, - projectId, - offset: (page - 1) * perPage, - limit: perPage - }); + const { data, isPending } = useGetPkiSubscriberCertificates( + { + subscriberName, + projectId, + offset: (page - 1) * perPage, + limit: perPage + }, + { + refetchInterval: 10 * 1000 // 10 seconds + } + ); const getCertStatusBadge = (status: string, notAfter: string) => { if (status === CertStatus.REVOKED) {