From dbd4a85fc816befcefb0308d1b2781f03e967aa0 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sun, 30 Nov 2025 20:42:34 -0300 Subject: [PATCH 1/8] Add external CA support, certificate requests and profile external configurations --- .infisicalignore | 1 + backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 10 + ...20251127120000_add-certificate-requests.ts | 47 ++ ...120000_add-pki-profile-external-configs.ts | 21 + .../src/db/schemas/certificate-requests.ts | 34 + backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../db/schemas/pki-certificate-profiles.ts | 3 +- .../ee/services/pki-acme/pki-acme-service.ts | 3 +- backend/src/queue/queue-service.ts | 17 + backend/src/server/routes/index.ts | 36 +- .../routes/v1/certificate-profiles-router.ts | 45 +- .../server/routes/v3/certificates-router.ts | 646 ++++++++++++++++-- .../acme/acme-certificate-authority-fns.ts | 179 ++++- .../azure-ad-cs-certificate-authority-fns.ts | 445 +++++++++++- ...ure-ad-cs-certificate-authority-schemas.ts | 7 + .../certificate-authority-service.ts | 51 +- .../certificate-issuance-queue.ts | 408 +++++++++++ .../certificate-profile-dal.ts | 87 ++- .../certificate-profile-service.test.ts | 18 +- .../certificate-profile-service.ts | 108 ++- .../certificate-profile-types.ts | 19 +- .../certificate-request-dal.ts | 144 ++++ .../certificate-request-service.test.ts | 561 +++++++++++++++ .../certificate-request-service.ts | 275 ++++++++ .../certificate-request-types.ts | 41 ++ .../certificate-v3/certificate-v3-fns.ts | 40 ++ .../certificate-v3-service.test.ts | 36 +- .../certificate-v3/certificate-v3-service.ts | 384 ++++++++--- .../certificate-v3/certificate-v3-types.ts | 3 + .../hooks/api/certificateProfiles/types.ts | 15 +- frontend/src/hooks/api/certificates/index.tsx | 2 +- .../src/hooks/api/certificates/mutations.tsx | 33 + .../src/hooks/api/certificates/queries.tsx | 28 +- frontend/src/hooks/api/certificates/types.ts | 49 ++ .../components/CertificateIssuanceModal.tsx | 123 ++-- .../components/CertificateRenewalModal.tsx | 8 +- .../components/CertificateRequestTracker.tsx | 108 +++ .../components/CertificatesSection.tsx | 2 +- .../CreateProfileModal.tsx | 200 +++++- .../CertificateProfilesTab/ProfileRow.tsx | 24 +- 42 files changed, 3949 insertions(+), 316 deletions(-) create mode 100644 backend/src/db/migrations/20251127120000_add-certificate-requests.ts create mode 100644 backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts create mode 100644 backend/src/db/schemas/certificate-requests.ts create mode 100644 backend/src/services/certificate-authority/certificate-issuance-queue.ts create mode 100644 backend/src/services/certificate-request/certificate-request-dal.ts create mode 100644 backend/src/services/certificate-request/certificate-request-service.test.ts create mode 100644 backend/src/services/certificate-request/certificate-request-service.ts create mode 100644 backend/src/services/certificate-request/certificate-request-types.ts create mode 100644 backend/src/services/certificate-v3/certificate-v3-fns.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx diff --git a/.infisicalignore b/.infisicalignore index 7a07a9504..d0f302ce0 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -57,3 +57,4 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93 docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139 docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62 docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61 +backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246 \ No newline at end of file diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 6ef775f90..02394de4d 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; @@ -288,6 +289,7 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateV3: TCertificateV3ServiceFactory; + certificateRequest: TCertificateRequestServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; certificateTemplateV2: TCertificateTemplateV2ServiceFactory; certificateProfile: TCertificateProfileServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 603df5f6c..4bdd3849d 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -573,6 +573,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate +} from "@app/db/schemas/certificate-requests"; import { TAccessApprovalPoliciesEnvironments, TAccessApprovalPoliciesEnvironmentsInsert, @@ -714,6 +719,11 @@ declare module "knex/types/tables" { TExternalCertificateAuthoritiesUpdate >; [TableName.Certificate]: KnexOriginal.CompositeTableType; + [TableName.CertificateRequests]: KnexOriginal.CompositeTableType< + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate + >; [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< TCertificateTemplates, TCertificateTemplatesInsert, diff --git a/backend/src/db/migrations/20251127120000_add-certificate-requests.ts b/backend/src/db/migrations/20251127120000_add-certificate-requests.ts new file mode 100644 index 000000000..32944c37d --- /dev/null +++ b/backend/src/db/migrations/20251127120000_add-certificate-requests.ts @@ -0,0 +1,47 @@ +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.CertificateRequests))) { + await knex.schema.createTable(TableName.CertificateRequests, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("status").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("profileId").nullable(); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.uuid("caId").nullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL"); + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.text("csr").nullable(); + t.string("commonName").nullable(); + t.text("altNames").nullable(); + t.specificType("keyUsages", "text[]").nullable(); + t.specificType("extendedKeyUsages", "text[]").nullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); + t.string("keyAlgorithm").nullable(); + t.string("signatureAlgorithm").nullable(); + t.text("errorMessage").nullable(); + t.text("metadata").nullable(); + + t.index(["projectId"]); + t.index(["status"]); + t.index(["profileId"]); + t.index(["caId"]); + t.index(["certificateId"]); + t.index(["createdAt"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.CertificateRequests); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateRequests); + await dropOnUpdateTrigger(knex, TableName.CertificateRequests); +} diff --git a/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts b/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts new file mode 100644 index 000000000..86ad4f5b4 --- /dev/null +++ b/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs"); + if (!hasExternalConfigs) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.text("externalConfigs").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs"); + if (hasExternalConfigs) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropColumn("externalConfigs"); + }); + } +} diff --git a/backend/src/db/schemas/certificate-requests.ts b/backend/src/db/schemas/certificate-requests.ts new file mode 100644 index 000000000..e01e08bbd --- /dev/null +++ b/backend/src/db/schemas/certificate-requests.ts @@ -0,0 +1,34 @@ +// 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 CertificateRequestsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + status: z.string(), + projectId: z.string(), + profileId: z.string().uuid().nullable().optional(), + caId: z.string().uuid().nullable().optional(), + certificateId: z.string().uuid().nullable().optional(), + csr: z.string().nullable().optional(), + commonName: z.string().nullable().optional(), + altNames: z.string().nullable().optional(), + keyUsages: z.string().array().nullable().optional(), + extendedKeyUsages: z.string().array().nullable().optional(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional(), + keyAlgorithm: z.string().nullable().optional(), + signatureAlgorithm: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + metadata: z.string().nullable().optional() +}); + +export type TCertificateRequests = z.infer; +export type TCertificateRequestsInsert = Omit, TImmutableDBKeys>; +export type TCertificateRequestsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 78dcb1980..7db6e847d 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -16,6 +16,7 @@ export * from "./certificate-authority-certs"; export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; +export * from "./certificate-requests"; export * from "./certificate-secrets"; export * from "./certificate-syncs"; export * from "./certificate-template-est-configs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 444a6bd97..040d6e278 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -21,6 +21,7 @@ export enum TableName { CertificateAuthorityCrl = "certificate_authority_crl", Certificate = "certificates", CertificateBody = "certificate_bodies", + CertificateRequests = "certificate_requests", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", PkiCertificateTemplateV2 = "pki_certificate_templates_v2", diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index c0dd891f0..0cf9cf160 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), acmeConfigId: z.string().uuid().nullable().optional(), - issuerType: z.string().default("ca") + issuerType: z.string().default("ca"), + externalConfigs: z.string().nullable().optional() }); export type TPkiCertificateProfiles = z.infer; diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 591cac688..71b7a7b70 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -94,7 +94,7 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; appConnectionDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateAuthorityDAL: Pick; externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; @@ -776,6 +776,7 @@ export const pkiAcmeServiceFactory = ({ const cert = await orderCertificate( { caId: certificateAuthority!.id, + profileId, commonName: certificateRequest.commonName!, altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), csr: Buffer.from(csrPem), diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8cf8555f9..a7ed3a170 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -63,6 +63,7 @@ export enum QueueName { DynamicSecretRevocation = "dynamic-secret-revocation", CaCrlRotation = "ca-crl-rotation", CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber + CertificateIssuance = "certificate-issuance", SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication PkiSync = "pki-sync", @@ -125,6 +126,7 @@ export enum QueueJobs { SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", + CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", @@ -343,6 +345,21 @@ export type TQueueJobTypes = { caType: CaType; }; }; + [QueueName.CertificateIssuance]: { + name: QueueJobs.CaIssueCertificateFromProfile; + payload: { + certificateId: string; + profileId: string; + caId: string; + commonName?: string; + altNames?: string[]; + ttl: string; + signatureAlgorithm: string; + keyAlgorithm: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + }; + }; [QueueName.DailyReminders]: { name: QueueJobs.DailyReminders; payload: undefined; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 00771168c..4d5a498a0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -173,6 +173,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { certificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue"; import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; @@ -180,6 +181,8 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { certificateRequestDALFactory } from "@app/services/certificate-request/certificate-request-dal"; +import { certificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; @@ -1092,6 +1095,7 @@ export const registerRoutes = async ( const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); + const certificateRequestDAL = certificateRequestDALFactory(db); const certificateSyncDAL = certificateSyncDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db); @@ -1187,7 +1191,7 @@ export const registerRoutes = async ( certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -2208,6 +2212,31 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateRequestService = certificateRequestServiceFactory({ + certificateRequestDAL, + certificateDAL, + certificateService, + permissionService + }); + + const certificateIssuanceQueue = certificateIssuanceQueueFactory({ + certificateAuthorityDAL, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + certificateDAL, + projectDAL, + kmsService, + certificateBodyDAL, + certificateSecretDAL, + queueService, + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL, + certificateRequestService + }); + const certificateV3Service = certificateV3ServiceFactory({ certificateDAL, certificateSecretDAL, @@ -2222,7 +2251,8 @@ export const registerRoutes = async ( pkiSyncQueue, kmsService, projectDAL, - certificateBodyDAL + certificateBodyDAL, + certificateIssuanceQueue }); const certificateV3Queue = certificateV3QueueServiceFactory({ @@ -2448,6 +2478,7 @@ export const registerRoutes = async ( await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiAlertV2Queue.init(); await certificateV3Queue.init(); + await certificateIssuanceQueue.initializeCertificateIssuanceQueue(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); @@ -2513,6 +2544,7 @@ export const registerRoutes = async ( auditLogStream: auditLogStreamService, certificate: certificateService, certificateV3: certificateV3Service, + certificateRequest: certificateRequestService, certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index b23dd1fee..371ee1d77 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -46,7 +46,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z.object({}).optional(), + externalConfigs: z.record(z.unknown()).nullable().optional() }) .refine( (data) => { @@ -149,7 +150,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: z.record(z.unknown()).nullable().optional() + }) }) } }, @@ -204,6 +207,15 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfiles: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + status: z.string(), + name: z.string(), + isExternal: z.boolean().optional(), + externalType: z.string().nullable().optional() + }) + .optional(), metrics: z .object({ profileId: z.string(), @@ -234,7 +246,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid id: z.string(), directoryUrl: z.string() }) - .optional() + .optional(), + externalConfigs: z.record(z.unknown()).nullable().optional() }).array(), totalCount: z.number() }) @@ -280,12 +293,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: z.record(z.unknown()).nullable().optional() + }).extend({ certificateAuthority: z .object({ id: z.string(), - projectId: z.string(), + projectId: z.string().optional(), status: z.string(), - name: z.string() + name: z.string(), + isExternal: z.boolean().optional(), + externalType: z.string().nullable().optional() }) .optional(), certificateTemplate: z @@ -310,7 +327,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), renewBeforeDays: z.number().optional() }) - .optional() + .optional(), + externalConfigs: z.record(z.unknown()).nullable().optional() }) }) } @@ -358,7 +376,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: z.record(z.unknown()).nullable().optional() + }) }) } }, @@ -412,7 +432,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + externalConfigs: z.record(z.unknown()).nullable().optional() }) .refine( (data) => { @@ -434,7 +455,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: z.record(z.unknown()).nullable().optional() + }) }) } }, @@ -479,7 +502,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: z.record(z.unknown()).nullable().optional() + }) }) } }, diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index f13f77c34..59a729505 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -1,9 +1,11 @@ import { z } from "zod"; +import { TCertificateRequests } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; +import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { writeLimit } from "@app/server/config/rateLimiter"; +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 { @@ -12,6 +14,7 @@ import { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -21,6 +24,7 @@ import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { booleanSchema } from "../sanitizedSchemas"; @@ -57,7 +61,398 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { return true; }; +const validateCertificateRequestFlow = (data: { + csr?: string; + subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>; + commonName?: string; + altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; +}) => { + const hasCSR = !!data.csr; + const hasSANs = !!data.subjectAlternativeNames?.length; + const hasStandardFields = !!(data.commonName || data.altNames?.length); + + const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields); + return flowCount === 1; +}; + export const registerCertificatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + projectId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + csr: z + .string() + .trim() + .min(1, "CSR cannot be empty") + .max(4096, "CSR cannot exceed 4096 characters") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }) + .refine(validateCertificateRequestFlow, { + message: + "Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)" + }), + response: { + 200: z.union([ + z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string(), + certificateRequestId: z.string() + }), + z.object({ + certificateRequestId: z.string(), + status: z.string(), + orderId: z.string().optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ) + .optional(), + authorizations: z + .array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ) + .optional(), + finalize: z.string().optional() + }) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { csr, subjectAlternativeNames, ...requestBody } = req.body; + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + profileId: requestBody.profileId, + csr, + commonName: requestBody.commonName, + altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + keyAlgorithm: requestBody.keyAlgorithm, + signatureAlgorithm: requestBody.signatureAlgorithm, + metadata: JSON.stringify({ + ttl: requestBody.ttl, + removeRootsFromChain: requestBody.removeRootsFromChain, + subjectAlternativeNames + }) + }); + + try { + if (csr) { + const extractedCsrData = extractCertificateRequestFromCSR(csr); + + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + csr, + validity: { ttl: requestBody.ttl }, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + enrollmentType: EnrollmentType.API, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: extractedCsrData.commonName || "" + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } + + const profile = await server.services.certificateProfile.getProfileById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId + }); + + let useOrderFlow = false; + if (profile?.caId) { + const ca = await server.services.certificateAuthority.getCaById({ + caId: profile.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; + useOrderFlow = caType !== CaType.INTERNAL; + } + + if (subjectAlternativeNames?.length || useOrderFlow) { + let acmeAltNames = subjectAlternativeNames; + if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) { + acmeAltNames = requestBody.altNames.map((alt) => ({ + type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME + ? ACMESANType.DNS + : ACMESANType.IP) as ACMESANType, + value: alt.value + })); + } + + const certificateOrderObject = { + altNames: acmeAltNames || [], + validity: { ttl: requestBody.ttl }, + commonName: requestBody.commonName, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + signatureAlgorithm: requestBody.signatureAlgorithm, + keyAlgorithm: requestBody.keyAlgorithm + }; + + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateOrder: certificateOrderObject, + removeRootsFromChain: requestBody.removeRootsFromChain, + certificateRequestId: certificateRequest.id + }); + + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.PENDING, + errorMessage: undefined + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return { + certificateRequestId: certificateRequest.id, + ...data + }; + } + const certificateRequestForService: CertificateRequestForService = { + commonName: requestBody.commonName, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + altNames: requestBody.altNames, + validity: { ttl: requestBody.ttl }, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + signatureAlgorithm: requestBody.signatureAlgorithm, + keyAlgorithm: requestBody.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + commonName: requestBody.commonName || "", + profileName: data.profileName + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } catch (error) { + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.FAILED, + errorMessage: error instanceof Error ? error.message : "Unknown error" + }); + throw error; + } + } + }); + + server.route({ + method: "GET", + url: "/certificate-requests/:certificateRequestId/certificate", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + certificateRequestId: z.string().uuid() + }), + query: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + status: z.nativeEnum(CertificateRequestStatus), + certificate: z.string().nullable(), + privateKey: z.string().nullable(), + serialNumber: z.string().nullable(), + errorMessage: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateRequest.getCertificateFromRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: (req.query as { projectId: string }).projectId, + certificateRequestId: req.params.certificateRequestId + }); + + if (data.certificate && data.serialNumber) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: (req.query as { projectId: string }).projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: req.params.certificateRequestId, + cn: "", + serialNumber: data.serialNumber || "" + } + } + }); + } + + return data; + } + }); + server.route({ method: "POST", url: "/issue-certificate", @@ -65,8 +460,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -106,7 +503,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, @@ -138,6 +536,28 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: req.body.keyAlgorithm, + signatureAlgorithm: req.body.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -152,7 +572,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -163,8 +586,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, // Hide deprecated endpoint from docs + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -191,14 +616,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => issuingCaCertificate: z.string().trim(), certificateChain: z.string().trim(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateRequest = extractCertificateRequestFromCSR(req.body.csr); - const data = await server.services.certificateV3.signCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, @@ -215,6 +639,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + csr: req.body.csr, + commonName: certificateRequestData.commonName, + altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: certificateRequestData.keyUsages, + extendedKeyUsages: certificateRequestData.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: certificateRequestData.keyAlgorithm, + signatureAlgorithm: certificateRequestData.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -224,12 +673,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: certificateRequest.commonName || "" + commonName: certificateRequestData.commonName || "" } } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -240,23 +692,23 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, // Hide deprecated endpoint from docs + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) - .min(1, "At least one subject alternative name must be provided"), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ), ttl: z .string() .trim() @@ -308,34 +760,54 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }) ), finalize: z.string(), - certificate: z.string().optional() + certificate: z.string().optional(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const certificateOrderObject = { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + const data = await server.services.certificateV3.orderCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateOrder: { - altNames: req.body.subjectAlternativeNames, - validity: { - ttl: req.body.ttl - }, - commonName: req.body.commonName, - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm - }, + certificateOrder: certificateOrderObject, removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -349,7 +821,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -377,36 +852,87 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const data = await server.services.certificateV3.renewCertificate({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - certificateId: req.params.certificateId, - removeRootsFromChain: req.body?.removeRootsFromChain - }); + let certificateRequest: TCertificateRequests | undefined; - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.RENEW_CERTIFICATE, - metadata: { - originalCertificateId: req.params.certificateId, - newCertificateId: data.certificateId, - profileName: data.profileName, - commonName: data.commonName - } + try { + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + serialNumber: req.params.certificateId + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); } - }); - return data; + certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: originalCertificate.cert.projectId, + profileId: originalCertificate.cert.profileId || undefined, + caId: originalCertificate.cert.caId ?? undefined, + metadata: JSON.stringify({ + operation: "renewal", + originalCertificateId: req.params.certificateId, + removeRootsFromChain: req.body?.removeRootsFromChain + }) + }); + + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId, + removeRootsFromChain: req.body?.removeRootsFromChain, + certificateRequestId: certificateRequest.id + }); + + if (data.certificate && data.certificate.trim() !== "") { + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.certificateId, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } catch (error) { + if (certificateRequest) { + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.FAILED, + errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal" + }); + } + throw error; + } } }); 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 ff95083c6..2e1eafbbe 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 @@ -1,6 +1,7 @@ import * as x509 from "@peculiar/x509"; import acme, { CsrBuffer } from "acme-client"; import { Knex } from "knex"; +import RE2 from "re2"; import { TableName } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; @@ -23,6 +24,7 @@ import { CertKeyUsage, CertStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; @@ -45,6 +47,60 @@ import { import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; + +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined, + ttl: string +): number | undefined => { + if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) { + return undefined; + } + + const certificateTtlInDays = parseTtlToDays(ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays); + + if (!renewBeforeDays) { + return undefined; + } + + return renewBeforeDays; +}; + type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; appConnectionService: Pick; @@ -53,7 +109,7 @@ type TAcmeCertificateAuthorityFnsDeps = { "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -64,13 +120,14 @@ type TAcmeCertificateAuthorityFnsDeps = { pkiSyncDAL: Pick; pkiSyncQueue: Pick; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type TOrderCertificateDeps = { appConnectionDAL: Pick; certificateAuthorityDAL: Pick; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -78,6 +135,7 @@ type TOrderCertificateDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type DBConfigurationColumn = { @@ -91,7 +149,7 @@ type DBConfigurationColumn = { export const castDbEntryToAcmeCertificateAuthority = ( ca: Awaited> -): TAcmeCertificateAuthority & { credentials: unknown } => { +): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => { if (!ca.externalCa?.id) { throw new BadRequestError({ message: "Malformed ACME certificate authority" }); } @@ -123,15 +181,22 @@ export const castDbEntryToAcmeCertificateAuthority = ( export const orderCertificate = async ( { caId, + profileId, subscriberId, commonName, altNames, csr, csrPrivateKey, keyUsages, - extendedKeyUsages + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId }: { caId: string; + profileId?: string; subscriberId?: string; commonName: string; altNames?: string[]; @@ -139,6 +204,11 @@ export const orderCertificate = async ( csrPrivateKey?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + ttl?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isRenewal?: boolean; + originalCertificateId?: string; }, deps: TOrderCertificateDeps, tx?: Knex @@ -151,7 +221,8 @@ export const orderCertificate = async ( certificateBodyDAL, certificateSecretDAL, kmsService, - projectDAL + projectDAL, + certificateProfileDAL } = deps; const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); @@ -181,7 +252,7 @@ export const orderCertificate = async ( let accountKey: Buffer | undefined; if (acmeCa.credentials) { const decryptedCredentials = await kmsDecryptor({ - cipherTextBlob: acmeCa.credentials as Buffer + cipherTextBlob: acmeCa.credentials }); const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( @@ -322,6 +393,7 @@ export const orderCertificate = async ( { caId: ca.id, pkiSubscriberId: subscriberId, + profileId, status: CertStatus.ACTIVE, friendlyName: commonName, commonName, @@ -331,11 +403,18 @@ export const orderCertificate = async ( notAfter: certObj.notAfter, keyUsages, extendedKeyUsages, - projectId: ca.projectId + keyAlgorithm, + signatureAlgorithm, + projectId: ca.projectId, + renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null }, innerTx ); + if (isRenewal && originalCertificateId) { + await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx); + } + await certificateBodyDAL.create( { certId: cert.id, @@ -355,6 +434,26 @@ export const orderCertificate = async ( ); } + if (profileId && ttl && certificateProfileDAL) { + const profile = await certificateProfileDAL.findById(profileId, innerTx); + if (profile) { + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile as { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, + ttl + ); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById( + cert.id, + { + renewBeforeDays: finalRenewBeforeDays + }, + innerTx + ); + } + } + } + return cert; }); }; @@ -371,7 +470,8 @@ export const AcmeCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAcmeCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -616,10 +716,71 @@ export const AcmeCertificateAuthorityFns = ({ await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; + const orderCertificateFromProfile = async ({ + caId, + profileId, + commonName, + altNames = [], + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }: { + caId: string; + profileId?: string; + commonName: string; + altNames?: string[]; + csr: CsrBuffer; + csrPrivateKey: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + ttl?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isRenewal?: boolean; + originalCertificateId?: string; + }) => { + return orderCertificate( + { + caId, + profileId, + subscriberId: undefined, + commonName, + altNames, + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }, + { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + certificateProfileDAL + } + ); + }; + return { createCertificateAuthority, updateCertificateAuthority, listCertificateAuthorities, - orderSubscriberCertificate + orderSubscriberCertificate, + orderCertificateFromProfile }; }; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 26f59a402..8b2e23bd4 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -21,8 +21,10 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage, - CertStatus + CertStatus, + TAltNameType } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; @@ -42,6 +44,60 @@ import { TUpdateAzureAdCsCertificateAuthorityDTO } from "./azure-ad-cs-certificate-authority-types"; +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; + +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined, + ttl: string +): number | undefined => { + const hasAutoRenewEnabled = profile?.apiConfig?.autoRenew === true; + if (!hasAutoRenewEnabled) { + return undefined; + } + + const profileRenewBeforeDays = profile?.apiConfig?.renewBeforeDays; + if (profileRenewBeforeDays !== undefined) { + const certificateTtlInDays = parseTtlToDays(ttl); + return calculateRenewalThreshold(profileRenewBeforeDays, certificateTtlInDays); + } + + return undefined; +}; + type TAzureAdCsCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; appConnectionService: Pick; @@ -50,7 +106,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -61,6 +117,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { pkiSyncDAL: Pick; pkiSyncQueue: Pick; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type AzureCertificateRequest = { @@ -190,7 +247,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie export const castDbEntryToAzureAdCsCertificateAuthority = ( ca: Awaited> -): TAzureAdCsCertificateAuthority & { credentials: unknown } => { +): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => { if (!ca.externalCa?.id) { throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" }); } @@ -591,7 +648,8 @@ export const AzureAdCsCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAzureAdCsCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -1043,6 +1101,384 @@ export const AzureAdCsCertificateAuthorityFns = ({ }; }; + const orderCertificateFromProfile = async ({ + caId, + profileId, + commonName, + altNames = [], + keyUsages = [], + extendedKeyUsages = [], + template, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm = CertKeyAlgorithm.RSA_2048, + isRenewal, + originalCertificateId + }: { + caId: string; + profileId: string; + commonName: string; + altNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + template?: string; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: CertKeyAlgorithm; + isRenewal?: boolean; + originalCertificateId?: string; + }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { + throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + if (azureCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + let alg; + if (signatureAlgorithm) { + switch (signatureAlgorithm.toUpperCase()) { + case "RSA-SHA256": + case "SHA256WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + break; + case "RSA-SHA384": + case "SHA384WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_3072); + break; + case "RSA-SHA512": + case "SHA512WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_4096); + break; + case "ECDSA-SHA256": + case "SHA256WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P256); + break; + case "ECDSA-SHA384": + case "SHA384WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P384); + break; + case "ECDSA-SHA512": + case "SHA512WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P521); + break; + default: + alg = keyAlgorithmToAlgCfg(keyAlgorithm); + break; + } + } else { + alg = keyAlgorithmToAlgCfg(keyAlgorithm); + } + + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const subjectDN = buildSubjectDN(commonName); + + let sanExtension = ""; + if (altNames && altNames.length > 0) { + sanExtension = altNames.join(","); + } + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: subjectDN, + keys: leafKeys, + signingAlgorithm: alg, + ...(sanExtension && { + extensions: [ + new x509.SubjectAlternativeNameExtension( + altNames.map((name) => ({ type: "dns" as TAltNameType, value: name })), + false + ) + ] + }) + }); + + const csrPem = csrObj.toString("pem"); + + let templateValue = template; + if (!templateValue) { + templateValue = "WebServer"; + } + + const templateInput = templateValue.trim(); + if (!templateInput || templateInput.length === 0) { + throw new BadRequestError({ + message: "Certificate template name cannot be empty" + }); + } + + let validityPeriod: string | undefined; + if (notBefore && notAfter) { + if (notAfter <= notBefore) { + throw new BadRequestError({ + message: "Certificate notAfter date must be after notBefore date" + }); + } + + const diffMs = notAfter.getTime() - notBefore.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + validityPeriod = `${diffDays}d`; + } else if (notAfter) { + const diffMs = notAfter.getTime() - Date.now(); + if (diffMs <= 0) { + throw new BadRequestError({ + message: "Certificate notAfter date must be in the future" + }); + } + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + validityPeriod = `${diffDays}d`; + } else if (validity.ttl) { + validityPeriod = validity.ttl; + } + + const certificateRequest: AzureCertificateRequest = { + csr: csrPem, + template: templateInput, + attributes: { + subject: subjectDN, + ...(sanExtension && { subjectAlternativeName: sanExtension }), + ...(validityPeriod && { validityPeriod }) + } + }; + + let submissionResponse; + const maxOidRetries = 3; + let oidRetryCount = 0; + + while (oidRetryCount <= maxOidRetries) { + try { + submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest); + break; + } catch (error) { + const isOidError = + error instanceof BadRequestError && + (error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type")); + + if (isOidError && oidRetryCount < maxOidRetries) { + oidRetryCount += 1; + + const delay = 3000 * oidRetryCount; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + throw error; + } + } + + if (!submissionResponse) { + throw new BadRequestError({ + message: "Failed to submit certificate request after multiple attempts due to OID resolution issues" + }); + } + + if (submissionResponse.status === "denied") { + throw new BadRequestError({ message: "Certificate request was denied by ADCS" }); + } + + let certificatePem = ""; + + if (submissionResponse.status === "issued" && submissionResponse.certificate) { + certificatePem = submissionResponse.certificate; + } else { + const maxRetries = 5; + const initialDelay = 2000; + let retryCount = 0; + let lastError: Error | null = null; + + // eslint-disable-next-line no-await-in-loop + while (retryCount < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId); + break; + } catch (error) { + lastError = error as Error; + // eslint-disable-next-line no-plusplus + retryCount++; + + if (retryCount < maxRetries) { + // Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s + const delay = initialDelay * 2 ** (retryCount - 1); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + } + + if (retryCount === maxRetries) { + throw new BadRequestError({ + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.` + }); + } + } + + if (!certificatePem) { + throw new BadRequestError({ + message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing." + }); + } + + let cleanedCertificatePem = certificatePem.trim(); + + if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Expected PEM format." + }); + } + + cleanedCertificatePem = cleanedCertificatePem + .replace(new RE2("\\r\\n", "g"), "\n") + .replace(new RE2("\\r", "g"), "\n") + .trim(); + + if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Missing end marker." + }); + } + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(cleanedCertificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = submissionResponse.certificateChain || ""; + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + let certificateId: string; + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + profileId, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames: altNames.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages, + extendedKeyUsages, + keyAlgorithm, + signatureAlgorithm, + projectId: ca.projectId, + renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null + }, + tx + ); + + certificateId = cert.id; + + if (isRenewal && originalCertificateId) { + await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx); + } + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + + if (profileId && validity?.ttl && certificateProfileDAL) { + const profile = await certificateProfileDAL.findById(profileId, tx); + if (profile) { + const finalRenewBeforeDays = calculateFinalRenewBeforeDays(undefined, validity.ttl); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById( + cert.id, + { + renewBeforeDays: finalRenewBeforeDays + }, + tx + ); + } + } + } + }); + + return { + certificate: cleanedCertificatePem, + certificateChain: certificateChainPem, + privateKey: skLeaf, + serialNumber: certObj.serialNumber, + certificateId: certificateId!, + ca: azureCa + }; + }; + const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca || ca.projectId !== projectId) { @@ -1182,6 +1618,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ updateCertificateAuthority, listCertificateAuthorities, orderSubscriberCertificate, + orderCertificateFromProfile, getTemplates }; }; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts index 2c2dfe484..004e3d4a5 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") }); +export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({ + username: z.string(), + password: z.string(), + sslRejectUnauthorized: z.boolean().optional(), + sslCertificate: z.string().optional() +}); + export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ type: z.literal(CaType.AZURE_AD_CS), configuration: AzureAdCsCertificateAuthorityConfigurationSchema diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index ed27f7571..132e8b148 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -11,6 +11,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; @@ -62,7 +63,7 @@ type TCertificateAuthorityServiceFactoryDep = { internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory; projectDAL: Pick; permissionService: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -72,6 +73,7 @@ type TCertificateAuthorityServiceFactoryDep = { pkiSubscriberDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + certificateProfileDAL?: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -90,7 +92,8 @@ export const certificateAuthorityServiceFactory = ({ kmsService, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TCertificateAuthorityServiceFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -104,7 +107,8 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -119,7 +123,8 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const createCertificateAuthority = async ( @@ -487,12 +492,48 @@ export const certificateAuthorityServiceFactory = ({ }); }; + const getCaById = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: { + caId: string; + actor: OrgServiceActor["type"]; + actorId: string; + actorAuthMethod: OrgServiceActor["authMethod"]; + actorOrgId?: string; + }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca) { + throw new NotFoundError({ message: "CA not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return ca; + }; + return { createCertificateAuthority, findCertificateAuthorityByNameAndProjectId, listCertificateAuthoritiesByProjectId, updateCertificateAuthority, deleteCertificateAuthority, - getAzureAdcsTemplates + getAzureAdcsTemplates, + getCaById }; }; diff --git a/backend/src/services/certificate-authority/certificate-issuance-queue.ts b/backend/src/services/certificate-authority/certificate-issuance-queue.ts new file mode 100644 index 000000000..846d0c4bc --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-issuance-queue.ts @@ -0,0 +1,408 @@ +import acme from "acme-client"; + +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "../certificate-request/certificate-request-types"; +import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; +import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { CaType } from "./certificate-authority-enums"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal"; + +export type TIssueCertificateFromProfileJobData = { + certificateId: string; + profileId: string; + caId: string; + commonName?: string; + altNames?: string[]; + ttl: string; + signatureAlgorithm: string; + keyAlgorithm: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + isRenewal?: boolean; + originalCertificateId?: string; + certificateRequestId?: string; +}; + +type TCertificateIssuanceQueueFactoryDep = { + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + appConnectionDAL: Pick; + appConnectionService: Pick; + externalCertificateAuthorityDAL: Pick; + certificateDAL: TCertificateDALFactory; + projectDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + queueService: TQueueServiceFactory; + pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + certificateProfileDAL?: Pick; + certificateRequestService?: Pick< + TCertificateRequestServiceFactory, + "attachCertificateToRequest" | "updateCertificateRequestStatus" + >; +}; + +export type TCertificateIssuanceQueueFactory = ReturnType; + +export const certificateIssuanceQueueFactory = ({ + certificateAuthorityDAL, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + certificateDAL, + projectDAL, + kmsService, + queueService, + certificateBodyDAL, + certificateSecretDAL, + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL, + certificateRequestService +}: TCertificateIssuanceQueueFactoryDep) => { + const validateKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { + if (!keyUsages) return []; + const validKeyUsages = Object.values(CertKeyUsage); + + if (Array.isArray(keyUsages)) { + return keyUsages.filter( + (usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage) + ); + } + + return []; + }; + + const validateExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { + if (!extendedKeyUsages) return []; + const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage); + + if (Array.isArray(extendedKeyUsages)) { + return extendedKeyUsages.filter( + (usage): usage is CertExtendedKeyUsage => + typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage) + ); + } + + return []; + }; + + const validateKeyAlgorithm = (keyAlgorithm: unknown): CertKeyAlgorithm | undefined => { + if (typeof keyAlgorithm !== "string") return undefined; + const validKeyAlgorithms = Object.values(CertKeyAlgorithm); + return validKeyAlgorithms.includes(keyAlgorithm as CertKeyAlgorithm) + ? (keyAlgorithm as CertKeyAlgorithm) + : undefined; + }; + const acmeFns = AcmeCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL + }); + + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL + }); + + /** + * Queue a certificate issuance job using pgBoss + */ + const queueCertificateIssuance = async ({ + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId + }: TIssueCertificateFromProfileJobData) => { + const jobData: TIssueCertificateFromProfileJobData = { + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId + }; + + await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, { + retryLimit: 3, + retryDelay: 5, + retryBackoff: true + }); + }; + + /** + * Process certificate issuance jobs + */ + const processCertificateIssuanceJobs = async (data: TIssueCertificateFromProfileJobData) => { + const { + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId + } = data; + + try { + logger.info(`Processing certificate issuance job for [certificateId=${certificateId}] [caId=${caId}]`); + + if (!caId) { + throw new NotFoundError({ + message: `Certificate authority ID is required for external CA certificate issuance` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + + if (ca.externalCa?.type === CaType.ACME) { + const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm); + if (!validatedKeyAlgorithm) { + throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` }); + } + const keyAlg = keyAlgorithmToAlgCfg(validatedKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const [, certificateCsr] = await acme.crypto.createCsr( + { + altNames: altNames || [], + commonName: commonName || "" + }, + skLeaf + ); + + const acmeResult = await acmeFns.orderCertificateFromProfile({ + caId, + profileId, + commonName: commonName || "", + altNames: altNames || [], + csr: certificateCsr, + csrPrivateKey: skLeaf, + keyUsages: validateKeyUsages(keyUsages), + extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages), + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }); + + if (certificateRequestId && certificateRequestService && acmeResult?.id) { + try { + await certificateRequestService.attachCertificateToRequest({ + certificateRequestId, + certificateId: acmeResult.id + }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); + } catch (attachError) { + logger.error( + attachError, + `Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]` + ); + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}` + }); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + } else if (ca.externalCa?.type === CaType.AZURE_AD_CS) { + let template: string | undefined; + if (certificateProfileDAL) { + try { + const profile = await certificateProfileDAL.findById(profileId); + if ( + profile?.externalConfigs && + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null + ) { + const configs = profile.externalConfigs; + if (typeof configs.template === "string") { + template = configs.template; + } + } + } catch (error) { + logger.warn( + `Failed to fetch profile ${profileId} for template extraction: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm); + if (!validatedKeyAlgorithm) { + throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` }); + } + + const azureParams = { + caId, + profileId, + commonName: commonName || "", + altNames: altNames || [], + keyUsages: validateKeyUsages(keyUsages), + extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages), + validity: { ttl }, + signatureAlgorithm, + keyAlgorithm: validatedKeyAlgorithm, + isRenewal, + originalCertificateId, + template + }; + + const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams); + + if (certificateRequestId && certificateRequestService && azureResult?.certificateId) { + try { + await certificateRequestService.attachCertificateToRequest({ + certificateRequestId, + certificateId: azureResult.certificateId + }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); + } catch (attachError) { + logger.error( + attachError, + `Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]` + ); + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}` + }); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + } + + logger.info( + `Successfully processed certificate issuance job with [certificateId=${certificateId}] [caId=${caId}]` + ); + } catch (error: unknown) { + logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`); + + if (certificateRequestId && certificateRequestService) { + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Certificate issuance failed: ${error instanceof Error ? error.message : String(error)}` + }); + logger.info(`Updated certificate request ${certificateRequestId} status to failed due to issuance error`); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + + throw error; + } + }; + + const initializeCertificateIssuanceQueue = async () => { + const appCfg = getConfig(); + + await queueService.startPg( + QueueJobs.CaIssueCertificateFromProfile, + async ([job]) => { + const data = job.data as TIssueCertificateFromProfileJobData; + await processCertificateIssuanceJobs(data); + }, + { + workerCount: appCfg.NODE_ENV === "production" ? 3 : 1, + batchSize: 1 + } + ); + + logger.info("Certificate issuance queue worker initialized successfully"); + }; + + return { + queueCertificateIssuance, + initializeCertificateIssuanceQueue, + processCertificateIssuanceJobs + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index dc74e2ea4..7a6494d34 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -22,10 +22,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { try { - const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ - TCertificateProfile - ]; - return certificateProfile; + const dataToInsert = { + ...data, + externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null + }; + + const [insertedProfile] = await (tx || db)(TableName.PkiCertificateProfile).insert(dataToInsert).returning("*"); + + return { + ...insertedProfile, + externalConfigs: insertedProfile.externalConfigs + ? (JSON.parse(insertedProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Create certificate profile" }); } @@ -33,11 +42,25 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { try { - const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + const dataToUpdate: Partial> = { + ...data + }; + + if (data.externalConfigs !== undefined) { + dataToUpdate.externalConfigs = data.externalConfigs ? JSON.stringify(data.externalConfigs) : null; + } + + const [updatedProfile] = await (tx || db)(TableName.PkiCertificateProfile) .where({ id }) - .update(data) - .returning("*")) as [TCertificateProfile]; - return certificateProfile; + .update(dataToUpdate) + .returning("*"); + + return { + ...updatedProfile, + externalConfigs: updatedProfile.externalConfigs + ? (JSON.parse(updatedProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Update certificate profile" }); } @@ -57,10 +80,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex): Promise => { try { - const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as - | TCertificateProfile - | undefined; - return certificateProfile; + const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first(); + + if (!certificateProfile) return undefined; + + return { + ...certificateProfile, + externalConfigs: certificateProfile.externalConfigs + ? (JSON.parse(certificateProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Find certificate profile by id" }); } @@ -203,6 +232,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => { estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, acmeConfigId: result.acmeConfigId, + externalConfigs: result.externalConfigs + ? (JSON.parse(result.externalConfigs) as Record) + : null, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, @@ -277,6 +309,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } const query = baseQuery + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.ExternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.ExternalCertificateAuthority}.caId` + ) .leftJoin( TableName.PkiEstEnrollmentConfig, `${TableName.PkiCertificateProfile}.estConfigId`, @@ -294,6 +336,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"), + db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalCaType"), db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), db .ref("disableBootstrapCaValidation") @@ -337,6 +384,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } : undefined; + const certificateAuthority = result.caId + ? { + id: result.caId as string, + name: result.caName as string, + status: result.caStatus as string, + isExternal: !!result.externalCaId, + externalType: result.externalCaType as string | undefined + } + : undefined; + const baseProfile = { id: result.id, projectId: result.projectId, @@ -349,11 +406,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => { estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, acmeConfigId: result.acmeConfigId, + externalConfigs: result.externalConfigs + ? (JSON.parse(result.externalConfigs as string) as Record) + : null, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, apiConfig, - acmeConfig + acmeConfig, + certificateAuthority }; return baseProfile as TCertificateProfileWithConfigs; diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 122dde8cd..17ddcaa30 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; -import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import type { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; @@ -100,6 +100,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + externalConfigs: null, createdAt: new Date(), updatedAt: new Date() }; @@ -229,17 +230,10 @@ describe("CertificateProfileService", () => { delete: vi.fn() } as unknown as TCertificateAuthorityDALFactory; - const mockCertificateAuthorityCertDAL = { - create: vi.fn(), + const mockExternalCertificateAuthorityDAL = { findById: vi.fn(), - updateById: vi.fn(), - deleteById: vi.fn(), - transaction: vi.fn(), - find: vi.fn(), - findOne: vi.fn(), - update: vi.fn(), - delete: vi.fn() - } as unknown as TCertificateAuthorityCertDALFactory; + findOne: vi.fn() + } as unknown as Pick; beforeEach(() => { vi.spyOn(ForbiddenError, "from").mockReturnValue({ @@ -261,7 +255,7 @@ describe("CertificateProfileService", () => { certificateBodyDAL: mockCertificateBodyDAL, certificateSecretDAL: mockCertificateSecretDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL, - certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL, permissionService: mockPermissionService, licenseService: mockLicenseService, kmsService: mockKmsService, diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 0ce3ca9b6..a771b7b0a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -19,8 +19,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; -import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaType } from "../certificate-authority/certificate-authority-enums"; +import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; @@ -68,6 +69,55 @@ const validateIssuerTypeConstraints = ( } }; +const validateTemplateByExternalCaType = ( + externalCaType: CaType | undefined, + externalConfigs: Record | null | undefined +) => { + if (!externalCaType) return; + + switch (externalCaType) { + case CaType.AZURE_AD_CS: + if (!externalConfigs?.template || typeof externalConfigs.template !== "string") { + throw new ForbiddenRequestError({ + message: "Azure ADCS Certificate Authority requires a template to be specified in external configs" + }); + } + break; + default: + break; + } +}; + +const validateExternalConfigs = async ( + externalConfigs: Record | null | undefined, + caId: string | null, + certificateAuthorityDAL: Pick, + externalCertificateAuthorityDAL: Pick +) => { + if (!externalConfigs) return; + + if (!caId) { + throw new ForbiddenRequestError({ + message: "External configs can only be specified when a Certificate Authority is selected" + }); + } + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const externalCa = await externalCertificateAuthorityDAL.findOne({ caId }); + + if (!externalCa) { + throw new ForbiddenRequestError({ + message: "External configs can only be specified for external Certificate Authorities" + }); + } + + validateTemplateByExternalCaType(externalCa.type as CaType, externalConfigs); +}; + const generateAndEncryptAcmeEabSecret = async ( projectId: string, kmsService: Pick, @@ -180,7 +230,7 @@ type TCertificateProfileServiceFactoryDep = { certificateBodyDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + externalCertificateAuthorityDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -190,10 +240,22 @@ type TCertificateProfileServiceFactoryDep = { export type TCertificateProfileServiceFactory = ReturnType; const convertDalToService = (dalResult: Record): TCertificateProfile => { + let parsedExternalConfigs: Record | null = null; + if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "string") { + try { + parsedExternalConfigs = JSON.parse(dalResult.externalConfigs) as Record; + } catch { + parsedExternalConfigs = null; + } + } else if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "object") { + parsedExternalConfigs = dalResult.externalConfigs as Record; + } + return { ...dalResult, enrollmentType: dalResult.enrollmentType as EnrollmentType, - issuerType: dalResult.issuerType as IssuerType + issuerType: dalResult.issuerType as IssuerType, + externalConfigs: parsedExternalConfigs } as TCertificateProfile; }; @@ -205,6 +267,8 @@ export const certificateProfileServiceFactory = ({ acmeEnrollmentConfigDAL, certificateBodyDAL, certificateSecretDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -272,6 +336,14 @@ export const certificateProfileServiceFactory = ({ validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); + // Validate external configs + await validateExternalConfigs( + data.externalConfigs, + data.caId ?? null, + certificateAuthorityDAL, + externalCertificateAuthorityDAL + ); + // Validate enrollment configuration requirements if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { throw new ForbiddenRequestError({ @@ -340,7 +412,8 @@ export const certificateProfileServiceFactory = ({ projectId, estConfigId, apiConfigId, - acmeConfigId + acmeConfigId, + externalConfigs: data.externalConfigs }, tx ); @@ -414,6 +487,16 @@ export const certificateProfileServiceFactory = ({ validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); + // Validate external configs only if they are provided in the update + if (data.externalConfigs !== undefined) { + await validateExternalConfigs( + data.externalConfigs, + finalCaId ?? null, + certificateAuthorityDAL, + externalCertificateAuthorityDAL + ); + } + const updatedData = finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; @@ -558,9 +641,24 @@ export const certificateProfileServiceFactory = ({ } } + // Parse externalConfigs from JSON string to object if it exists + let parsedExternalConfigs: Record | null = null; + if (profile.externalConfigs && typeof profile.externalConfigs === "string") { + try { + parsedExternalConfigs = JSON.parse(profile.externalConfigs) as Record; + } catch { + // If parsing fails, leave as null + parsedExternalConfigs = null; + } + } else if (profile.externalConfigs && typeof profile.externalConfigs === "object") { + // Already an object, use as-is + parsedExternalConfigs = profile.externalConfigs; + } + return { ...profile, - enrollmentType: profile.enrollmentType as EnrollmentType + enrollmentType: profile.enrollmentType as EnrollmentType, + externalConfigs: parsedExternalConfigs }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 85260b25d..41c1b4746 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -15,19 +15,28 @@ export enum IssuerType { SELF_SIGNED = "self-signed" } -export type TCertificateProfile = Omit & { +export type TCertificateProfile = Omit & { enrollmentType: EnrollmentType; issuerType: IssuerType; + externalConfigs?: Record | null; }; -export type TCertificateProfileInsert = Omit & { +export type TCertificateProfileInsert = Omit< + TPkiCertificateProfilesInsert, + "enrollmentType" | "issuerType" | "externalConfigs" +> & { enrollmentType: EnrollmentType; issuerType: IssuerType; + externalConfigs?: Record | null; }; -export type TCertificateProfileUpdate = Omit & { +export type TCertificateProfileUpdate = Omit< + TPkiCertificateProfilesUpdate, + "enrollmentType" | "issuerType" | "externalConfigs" +> & { enrollmentType?: EnrollmentType; issuerType?: IssuerType; + externalConfigs?: Record | null; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; @@ -47,9 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { }; certificateAuthority?: { id: string; - projectId: string; + projectId?: string; status: string; name: string; + isExternal?: boolean; + externalType?: string; }; certificateTemplate?: { id: string; diff --git a/backend/src/services/certificate-request/certificate-request-dal.ts b/backend/src/services/certificate-request/certificate-request-dal.ts new file mode 100644 index 000000000..93d13f0ba --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-dal.ts @@ -0,0 +1,144 @@ +import { TDbClient } from "@app/db"; +import { TableName, TCertificateRequests } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +type TCertificateRequestWithCertificateFlat = TCertificateRequests & { + certificateId?: string | null; + certificateSerialNumber?: string | null; + certificateFriendlyName?: string | null; + certificateCommonName?: string | null; + certificateAltNames?: string | null; + certificateStatus?: string | null; + certificateNotBefore?: Date | null; + certificateNotAfter?: Date | null; + certificateKeyUsages?: string[] | null; + certificateExtendedKeyUsages?: string[] | null; +}; + +type TCertificateInfo = { + id: string; + serialNumber: string; + friendlyName: string | null; + commonName: string; + altNames: string | null; + status: string; + notBefore: Date; + notAfter: Date; + keyUsages: string[] | null; + extendedKeyUsages: string[] | null; +}; + +type TCertificateRequestWithCertificate = TCertificateRequests & { + certificate: TCertificateInfo | null; +}; + +export type TCertificateRequestDALFactory = ReturnType; + +export const certificateRequestDALFactory = (db: TDbClient) => { + const certificateRequestOrm = ormify(db, TableName.CertificateRequests); + + const findByIdWithCertificate = async (id: string): Promise => { + try { + const certificateRequest = (await db(TableName.CertificateRequests) + .leftJoin( + TableName.Certificate, + `${TableName.CertificateRequests}.certificateId`, + `${TableName.Certificate}.id` + ) + .where(`${TableName.CertificateRequests}.id`, id) + .select( + `${TableName.CertificateRequests}.*`, + `${TableName.Certificate}.id as certificateId`, + `${TableName.Certificate}.serialNumber as certificateSerialNumber`, + `${TableName.Certificate}.friendlyName as certificateFriendlyName`, + `${TableName.Certificate}.commonName as certificateCommonName`, + `${TableName.Certificate}.altNames as certificateAltNames`, + `${TableName.Certificate}.status as certificateStatus`, + `${TableName.Certificate}.notBefore as certificateNotBefore`, + `${TableName.Certificate}.notAfter as certificateNotAfter`, + `${TableName.Certificate}.keyUsages as certificateKeyUsages`, + `${TableName.Certificate}.extendedKeyUsages as certificateExtendedKeyUsages` + ) + .first()) as TCertificateRequestWithCertificateFlat | undefined; + + if (!certificateRequest) return null; + + // Transform the flat result into nested structure + const { + certificateId, + certificateSerialNumber, + certificateFriendlyName, + certificateCommonName, + certificateAltNames, + certificateStatus, + certificateNotBefore, + certificateNotAfter, + certificateKeyUsages, + certificateExtendedKeyUsages, + ...certificateRequestData + } = certificateRequest; + + return { + ...certificateRequestData, + certificate: certificateId + ? { + id: certificateId, + serialNumber: certificateSerialNumber as string, + friendlyName: certificateFriendlyName || null, + commonName: certificateCommonName as string, + altNames: certificateAltNames || null, + status: certificateStatus as string, + notBefore: certificateNotBefore as Date, + notAfter: certificateNotAfter as Date, + keyUsages: certificateKeyUsages || null, + extendedKeyUsages: certificateExtendedKeyUsages || null + } + : null + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" }); + } + }; + + const findPendingByProjectId = async (projectId: string): Promise => { + try { + return (await db(TableName.CertificateRequests) + .where({ projectId, status: "pending" }) + .orderBy("createdAt", "desc")) as TCertificateRequests[]; + } catch (error) { + throw new DatabaseError({ error, name: "Find pending certificate requests by project ID" }); + } + }; + + const updateStatus = async (id: string, status: string, errorMessage?: string): Promise => { + try { + const updateData: Partial = { status }; + if (errorMessage !== undefined) { + updateData.errorMessage = errorMessage; + } + return await certificateRequestOrm.updateById(id, updateData); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate request status" }); + } + }; + + const attachCertificate = async (id: string, certificateId: string): Promise => { + try { + return await certificateRequestOrm.updateById(id, { + certificateId, + status: "issued" + }); + } catch (error) { + throw new DatabaseError({ error, name: "Attach certificate to request" }); + } + }; + + return { + ...certificateRequestOrm, + findByIdWithCertificate, + findPendingByProjectId, + updateStatus, + attachCertificate + }; +}; diff --git a/backend/src/services/certificate-request/certificate-request-service.test.ts b/backend/src/services/certificate-request/certificate-request-service.test.ts new file mode 100644 index 000000000..fec4ce9ae --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -0,0 +1,561 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { createMongoAbility, ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorType, AuthMethod } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; + +import { TCertificateRequestDALFactory } from "./certificate-request-dal"; +import { certificateRequestServiceFactory, TCertificateRequestServiceFactory } from "./certificate-request-service"; +import { CertificateRequestStatus } from "./certificate-request-types"; + +describe("CertificateRequestService", () => { + let service: TCertificateRequestServiceFactory; + + const mockCertificateRequestDAL: Pick< + TCertificateRequestDALFactory, + "create" | "findById" | "findByIdWithCertificate" | "updateStatus" | "attachCertificate" + > = { + create: vi.fn() as any, + findById: vi.fn() as any, + findByIdWithCertificate: vi.fn() as any, + updateStatus: vi.fn() as any, + attachCertificate: vi.fn() as any + }; + + const mockCertificateDAL: Pick = { + findById: vi.fn() as any + }; + + const mockCertificateService: Pick = { + getCertBody: vi.fn() as any, + getCertPrivateKey: vi.fn() as any + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn() as any + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = certificateRequestServiceFactory({ + certificateRequestDAL: mockCertificateRequestDAL as TCertificateRequestDALFactory, + certificateDAL: mockCertificateDAL, + certificateService: mockCertificateService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createCertificateRequest", () => { + const mockCreateData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com" + }; + + it("should create certificate request successfully", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Create, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCreatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + status: CertificateRequestStatus.PENDING, + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.create as any).mockResolvedValue(mockCreatedRequest); + + const result = await service.createCertificateRequest(mockCreateData); + + expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({ + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + projectId: "550e8400-e29b-41d4-a716-446655440003", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + actionProjectType: ActionProjectType.CertificateManager + }); + expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith( + expect.objectContaining({ + status: CertificateRequestStatus.PENDING, + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com" + }) + ); + expect(result).toEqual(mockCreatedRequest); + }); + + it("should throw ForbiddenError when user lacks permission", async () => { + const mockPermission = { + permission: ForbiddenError.from(createMongoAbility([])) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + + await expect(service.createCertificateRequest(mockCreateData)).rejects.toThrow(); + }); + }); + + describe("getCertificateRequest", () => { + const mockGetData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + certificateRequestId: "550e8400-e29b-41d4-a716-446655440005" + }; + + it("should get certificate request successfully", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.PENDING + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + + const result = await service.getCertificateRequest(mockGetData); + + expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({ + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + projectId: "550e8400-e29b-41d4-a716-446655440003", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + actionProjectType: ActionProjectType.CertificateManager + }); + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440005"); + expect(result).toEqual(mockRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError); + }); + + it("should throw BadRequestError when certificate request belongs to different project", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440099", + status: CertificateRequestStatus.PENDING + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + + await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(BadRequestError); + }); + }); + + describe("getCertificateFromRequest", () => { + const mockGetData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + certificateRequestId: "550e8400-e29b-41d4-a716-446655440005" + }; + + it("should get certificate from request successfully when certificate is attached", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440006", + serialNumber: "123456", + commonName: "test.example.com" + }; + const mockRequestWithCert = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.ISSUED, + certificate: mockCertificate, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const mockCertBody = { + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----" + }; + const mockPrivateKey = { + certPrivateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); + (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); + (mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440005" + ); + expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ + serialNumber: "123456", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ + serialNumber: "123456", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(result).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----", + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + }); + + it("should get certificate from request successfully when no certificate is attached", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequestWithoutCert = { + id: "550e8400-e29b-41d4-a716-446655440007", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.PENDING, + certificate: null, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(result).toEqual({ + status: CertificateRequestStatus.PENDING, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: null, + createdAt: mockRequestWithoutCert.createdAt, + updatedAt: mockRequestWithoutCert.updatedAt + }); + }); + + it("should get certificate from request successfully when private key access is denied", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440008", + serialNumber: "123456", + commonName: "test.example.com" + }; + const mockRequestWithCert = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.ISSUED, + certificate: mockCertificate, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const mockCertBody = { + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); + (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); + (mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key access denied")); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440005" + ); + expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ + serialNumber: "123456", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ + serialNumber: "123456", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(result).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: null, + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + }); + + it("should get certificate from request with error message when failed", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockFailedRequest = { + id: "550e8400-e29b-41d4-a716-446655440010", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.FAILED, + certificate: null, + errorMessage: "Certificate issuance failed", + createdAt: new Date(), + updatedAt: new Date() + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(result).toEqual({ + status: CertificateRequestStatus.FAILED, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: "Certificate issuance failed", + createdAt: mockFailedRequest.createdAt, + updatedAt: mockFailedRequest.updatedAt + }); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(null); + + await expect(service.getCertificateFromRequest(mockGetData)).rejects.toThrow(NotFoundError); + }); + }); + + describe("updateCertificateRequestStatus", () => { + it("should update certificate request status successfully", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.PENDING + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.ISSUED + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.ISSUED + }); + + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440011"); + expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440011", + CertificateRequestStatus.ISSUED, + undefined + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should update certificate request status with error message", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.PENDING + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.FAILED + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.FAILED, + errorMessage: "Certificate issuance failed" + }); + + expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440012", + CertificateRequestStatus.FAILED, + "Certificate issuance failed" + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440013", + status: CertificateRequestStatus.ISSUED + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("attachCertificateToRequest", () => { + it("should attach certificate to request successfully", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440014", + status: CertificateRequestStatus.PENDING + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440015" + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440014", + status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440015" + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateDAL.findById as any).mockResolvedValue(mockCertificate); + (mockCertificateRequestDAL.attachCertificate as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440014", + certificateId: "550e8400-e29b-41d4-a716-446655440015" + }); + + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440014"); + expect(mockCertificateDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440015"); + expect(mockCertificateRequestDAL.attachCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440014", + "550e8400-e29b-41d4-a716-446655440015" + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect( + service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440016", + certificateId: "550e8400-e29b-41d4-a716-446655440017" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw NotFoundError when certificate does not exist", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440018", + status: CertificateRequestStatus.PENDING + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateDAL.findById as any).mockResolvedValue(null); + + await expect( + service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440018", + certificateId: "550e8400-e29b-41d4-a716-446655440019" + }) + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts new file mode 100644 index 000000000..b27783822 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -0,0 +1,275 @@ +import { ForbiddenError } from "@casl/ability"; +import { z } from "zod"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; + +import { TCertificateRequestDALFactory } from "./certificate-request-dal"; +import { + CertificateRequestStatus, + TAttachCertificateToRequestDTO, + TCreateCertificateRequestDTO, + TGetCertificateFromRequestDTO, + TGetCertificateRequestDTO, + TUpdateCertificateRequestStatusDTO +} from "./certificate-request-types"; + +type TCertificateRequestServiceFactoryDep = { + certificateRequestDAL: TCertificateRequestDALFactory; + certificateDAL: Pick; + certificateService: Pick; + permissionService: Pick; +}; + +export type TCertificateRequestServiceFactory = ReturnType; + +// Input validation schemas +const certificateRequestDataSchema = z + .object({ + profileId: z.string().uuid().optional(), + caId: z.string().uuid().optional(), + csr: z.string().min(1).optional(), + commonName: z.string().min(1).max(255).optional(), + altNames: z.string().max(1000).optional(), + keyUsages: z.array(z.string()).max(20).optional(), + extendedKeyUsages: z.array(z.string()).max(20).optional(), + notBefore: z.date().optional(), + notAfter: z.date().optional(), + keyAlgorithm: z.string().max(100).optional(), + signatureAlgorithm: z.string().max(100).optional(), + metadata: z.string().max(2000).optional() + }) + .refine( + (data) => { + // Must have either profileId or caId + return data.profileId || data.caId; + }, + { + message: "Either profileId or caId must be provided" + } + ) + .refine( + (data) => { + // If notAfter is provided, it must be after notBefore + if (data.notBefore && data.notAfter) { + return data.notAfter > data.notBefore; + } + return true; + }, + { + message: "notAfter must be after notBefore" + } + ); + +const validateCertificateRequestData = (data: unknown) => { + try { + return certificateRequestDataSchema.parse(data); + } catch (error) { + if (error instanceof z.ZodError) { + throw new BadRequestError({ + message: `Invalid certificate request data: ${error.errors.map((e) => e.message).join(", ")}` + }); + } + throw error; + } +}; + +export const certificateRequestServiceFactory = ({ + certificateRequestDAL, + certificateDAL, + certificateService, + permissionService +}: TCertificateRequestServiceFactoryDep) => { + const createCertificateRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + ...requestData + }: TCreateCertificateRequestDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + + // Validate input data before creating the request + const validatedData = validateCertificateRequestData(requestData); + + const certificateRequest = await certificateRequestDAL.create({ + status: CertificateRequestStatus.PENDING, + projectId, + ...validatedData + }); + + return certificateRequest; + }; + + const getCertificateRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + certificateRequestId + }: TGetCertificateRequestDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + if (certificateRequest.projectId !== projectId) { + throw new BadRequestError({ message: "Certificate request does not belong to this project" }); + } + + return certificateRequest; + }; + + const getCertificateFromRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + certificateRequestId + }: TGetCertificateFromRequestDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + if (certificateRequest.projectId !== projectId) { + throw new BadRequestError({ message: "Certificate request does not belong to this project" }); + } + + // If no certificate is attached, return basic info + if (!certificateRequest.certificate) { + return { + status: certificateRequest.status as CertificateRequestStatus, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }; + } + + // Get certificate body (PEM data) + const certBody = await certificateService.getCertBody({ + serialNumber: certificateRequest.certificate.serialNumber, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + // Try to get private key (may fail if user doesn't have permission) + let privateKey: string | null = null; + try { + const certPrivateKey = await certificateService.getCertPrivateKey({ + serialNumber: certificateRequest.certificate.serialNumber, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + privateKey = certPrivateKey.certPrivateKey; + } catch (error) { + // Private key access denied - continue without it + privateKey = null; + } + + return { + status: certificateRequest.status as CertificateRequestStatus, + certificate: certBody.certificate, + privateKey, + serialNumber: certificateRequest.certificate.serialNumber, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }; + }; + + const updateCertificateRequestStatus = async ({ + certificateRequestId, + status, + errorMessage + }: TUpdateCertificateRequestStatusDTO) => { + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + return certificateRequestDAL.updateStatus(certificateRequestId, status, errorMessage); + }; + + const attachCertificateToRequest = async ({ + certificateRequestId, + certificateId + }: TAttachCertificateToRequestDTO) => { + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId); + }; + + return { + createCertificateRequest, + getCertificateRequest, + getCertificateFromRequest, + updateCertificateRequestStatus, + attachCertificateToRequest + }; +}; diff --git a/backend/src/services/certificate-request/certificate-request-types.ts b/backend/src/services/certificate-request/certificate-request-types.ts new file mode 100644 index 000000000..98fe05fe5 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -0,0 +1,41 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum CertificateRequestStatus { + PENDING = "pending", + ISSUED = "issued", + FAILED = "failed" +} + +export type TCreateCertificateRequestDTO = TProjectPermission & { + profileId?: string; + caId?: string; + csr?: string; + commonName?: string; + altNames?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + notBefore?: Date; + notAfter?: Date; + keyAlgorithm?: string; + signatureAlgorithm?: string; + metadata?: string; +}; + +export type TGetCertificateRequestDTO = TProjectPermission & { + certificateRequestId: string; +}; + +export type TGetCertificateFromRequestDTO = TProjectPermission & { + certificateRequestId: string; +}; + +export type TUpdateCertificateRequestStatusDTO = { + certificateRequestId: string; + status: CertificateRequestStatus; + errorMessage?: string; +}; + +export type TAttachCertificateToRequestDTO = { + certificateRequestId: string; + certificateId: string; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-fns.ts b/backend/src/services/certificate-v3/certificate-v3-fns.ts new file mode 100644 index 000000000..58a3a5d20 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-fns.ts @@ -0,0 +1,40 @@ +import RE2 from "re2"; + +import { BadRequestError } from "@app/lib/errors"; + +export const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +export const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + // If renewBeforeDays >= TTL, renew 1 day before expiry + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 93291b051..bab58385f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -42,7 +42,7 @@ describe("CertificateV3Service", () => { const mockCertificateDAL: Pick< TCertificateDALFactory, - "findOne" | "findById" | "updateById" | "transaction" | "create" + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" > = { findOne: vi.fn(), findById: vi.fn(), @@ -57,7 +57,8 @@ describe("CertificateV3Service", () => { transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const mockTx = {}; return callback(mockTx); - }) + }), + find: vi.fn().mockResolvedValue([]) }; const mockCertificateSecretDAL: Pick = { @@ -65,12 +66,24 @@ describe("CertificateV3Service", () => { create: vi.fn() }; - const mockCertificateAuthorityDAL: Pick = { - findByIdWithAssociatedCa: vi.fn() + const mockCertificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "create" | "updateById" | "findById" | "transaction" | "findWithAssociatedCa" + > = { + findByIdWithAssociatedCa: vi.fn(), + create: vi.fn().mockResolvedValue({ id: "ca-123" }), + updateById: vi.fn().mockResolvedValue({ id: "ca-123" }), + findById: vi.fn().mockResolvedValue({ id: "ca-123" }), + findWithAssociatedCa: vi.fn().mockResolvedValue([]), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) }; - const mockCertificateProfileDAL: Pick = { - findByIdWithConfigs: vi.fn() + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn(), + findById: vi.fn() }; const mockCertificateTemplateV2Service: Pick< @@ -168,7 +181,11 @@ describe("CertificateV3Service", () => { kmsService: { generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"), encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))), - decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))) + decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))), + createCipherPairWithDataKey: vi.fn().mockResolvedValue({ + cipherTextBlob: Buffer.from("encrypted"), + plainTextKey: Buffer.from("plainkey") + }) }, projectDAL: { findOne: vi.fn().mockResolvedValue({ id: "project-123" }), @@ -178,7 +195,10 @@ describe("CertificateV3Service", () => { const mockTx = {}; return callback(mockTx); }) - } as any + } as any, + certificateIssuanceQueue: { + queueCertificateIssuance: vi.fn().mockResolvedValue(undefined) + } }); }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 0fadbc8de..d8fb33cd0 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -40,7 +40,11 @@ import { } from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; +import { + EnrollmentType, + IssuerType, + TCertificateProfileWithConfigs +} from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -85,11 +89,17 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" + >; certificateBodyDAL: Pick; certificateSecretDAL: Pick; - certificateAuthorityDAL: Pick; - certificateProfileDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById" + >; + certificateProfileDAL: Pick; acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, @@ -103,8 +113,15 @@ type TCertificateV3ServiceFactoryDep = { >; pkiSyncDAL: Pick; pkiSyncQueue: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey" + >; projectDAL: TProjectDALFactory; + certificateIssuanceQueue: Pick< + import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory, + "queueCertificateIssuance" + >; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -294,14 +311,57 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { if (!keyUsages) return []; - if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[]; - return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage); + + const validKeyUsages = Object.values(CertKeyUsage); + + if (Array.isArray(keyUsages)) { + return keyUsages.filter( + (usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage) + ); + } + + if (typeof keyUsages === "string") { + return keyUsages + .split(",") + .map((usage) => usage.trim()) + .filter((usage): usage is CertKeyUsage => validKeyUsages.includes(usage as CertKeyUsage)); + } + + return []; }; const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { if (!extendedKeyUsages) return []; - if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[]; - return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage); + + const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage); + + if (Array.isArray(extendedKeyUsages)) { + return extendedKeyUsages.filter( + (usage): usage is CertExtendedKeyUsage => + typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage) + ); + } + + if (typeof extendedKeyUsages === "string") { + return extendedKeyUsages + .split(",") + .map((usage) => usage.trim()) + .filter((usage): usage is CertExtendedKeyUsage => validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)); + } + + return []; +}; + +const convertEnumsToStringArray = (enumArray: T[]): string[] => { + return enumArray.map((item) => item as string); +}; + +const combineKeyUsageFlags = (keyUsages: string[]): number => { + return keyUsages.reduce((acc: number, usage) => { + const flag = x509.KeyUsageFlags[usage as keyof typeof x509.KeyUsageFlags]; + // eslint-disable-next-line no-bitwise + return typeof flag === "number" ? acc | flag : acc; + }, 0); }; const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => { @@ -441,11 +501,7 @@ const generateSelfSignedCertificate = async ({ ...(certificateRequest.keyUsages?.length ? [ new x509.KeyUsagesExtension( - (convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce( - // eslint-disable-next-line no-bitwise - (acc: number, usage) => acc | x509.KeyUsageFlags[usage], - 0 - ), + combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []), false ) ] @@ -759,6 +815,37 @@ const processSelfSignedCertificate = async ({ }; }; +const detectSanType = (value: string): { type: CertSubjectAlternativeNameType; value: string } => { + const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(value); + const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(value); + + if (isIpv4 || isIpv6) { + return { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + value + }; + } + + if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(value)) { + return { + type: CertSubjectAlternativeNameType.EMAIL, + value + }; + } + + if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(value)) { + return { + type: CertSubjectAlternativeNameType.URI, + value + }; + } + + return { + type: CertSubjectAlternativeNameType.DNS_NAME, + value + }; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, certificateBodyDAL, @@ -773,7 +860,8 @@ export const certificateV3ServiceFactory = ({ pkiSyncDAL, pkiSyncQueue, kmsService, - projectDAL + projectDAL, + certificateIssuanceQueue }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -1099,7 +1187,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - removeRootsFromChain + removeRootsFromChain, + certificateRequestId }: TOrderCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -1117,7 +1206,7 @@ export const certificateV3ServiceFactory = ({ commonName: certificateOrder.commonName, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames.map((san) => { + subjectAlternativeNames: certificateOrder.altNames?.map((san) => { let certType: CertSubjectAlternativeNameType; switch (san.type) { case "dns": @@ -1197,17 +1286,69 @@ export const certificateV3ServiceFactory = ({ }; } - if (caType === CaType.ACME) { - throw new BadRequestError({ - message: "ACME certificate ordering via profiles is not yet implemented." + if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) { + const orderId = randomUUID(); + + await certificateIssuanceQueue.queueCertificateIssuance({ + certificateId: orderId, + profileId: profile.id, + caId: profile.caId || "", + ttl: certificateOrder.validity?.ttl || "1y", + signatureAlgorithm: certificateOrder.signatureAlgorithm || "", + keyAlgorithm: certificateOrder.keyAlgorithm || "", + commonName: certificateOrder.commonName || "", + altNames: certificateOrder.altNames?.map((san) => san.value) || [], + keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [], + extendedKeyUsages: certificateOrder.extendedKeyUsages + ? convertEnumsToStringArray(certificateOrder.extendedKeyUsages) + : [], + certificateRequestId }); + + return { + orderId, + status: CertificateOrderStatus.PENDING, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type, + value: san.value, + status: CertificateOrderStatus.PENDING + })), + authorizations: [], + finalize: `/api/v3/pki/certificates/orders/${orderId}/finalize`, + projectId: profile.projectId, + profileName: profile.slug + }; } throw new BadRequestError({ - message: `Certificate ordering is not supported for CA type: ${caType}` + message: "Certificate ordering is not supported for the specified CA type" }); }; + // Type for internal CA renewal result + type TInternalRenewalData = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + serialNumber: string; + newCert: TCertificates; + originalCert: TCertificates; + profile: TCertificateProfileWithConfigs | null; + }; + + // Type for external CA renewal result + type TExternalRenewalData = { + isExternalCA: true; + ca: TCertificateAuthorityWithAssociatedCa; + profile: TCertificateProfileWithConfigs | null; + originalCert: TCertificates; + originalSignatureAlgorithm: CertSignatureAlgorithm; + originalKeyAlgorithm: CertKeyAlgorithm; + ttl: string; + }; + + type TRenewalTransactionResult = TInternalRenewalData | TExternalRenewalData; + const renewCertificate = async ({ certificateId, actor, @@ -1215,9 +1356,10 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, internal = false, - removeRootsFromChain + removeRootsFromChain, + certificateRequestId }: TRenewCertificateDTO & { internal?: boolean }): Promise => { - const renewalResult = await certificateDAL.transaction(async (tx) => { + const renewalResult: TRenewalTransactionResult = await certificateDAL.transaction(async (tx) => { const originalCert = await certificateDAL.findById(certificateId, tx); if (!originalCert) { throw new NotFoundError({ message: "Certificate not found" }); @@ -1229,14 +1371,30 @@ export const certificateV3ServiceFactory = ({ }); } - const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; - const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; + // Validate and cast algorithms with fallbacks + let originalSignatureAlgorithm = Object.values(CertSignatureAlgorithm).includes( + originalCert.signatureAlgorithm as CertSignatureAlgorithm + ) + ? (originalCert.signatureAlgorithm as CertSignatureAlgorithm) + : CertSignatureAlgorithm.RSA_SHA256; + let originalKeyAlgorithm = Object.values(CertKeyAlgorithm).includes(originalCert.keyAlgorithm as CertKeyAlgorithm) + ? (originalCert.keyAlgorithm as CertKeyAlgorithm) + : CertKeyAlgorithm.RSA_2048; + // For external CA certificates without stored algorithm info, extract from certificate if (!originalSignatureAlgorithm || !originalKeyAlgorithm) { - throw new BadRequestError({ - message: - "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." - }); + const isExternalCA = originalCert.caId && !originalCert.caId.startsWith("internal"); + + if (isExternalCA) { + // For external CA certificates, we can extract algorithm info from the cert or use defaults + originalSignatureAlgorithm = originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256; + originalKeyAlgorithm = originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048; + } else { + throw new BadRequestError({ + message: + "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." + }); + } } let profile = null; @@ -1303,7 +1461,10 @@ export const certificateV3ServiceFactory = ({ }); } - validateCaSupport(ca, "direct certificate issuance"); + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType === CaType.INTERNAL) { + validateCaSupport(ca, "direct certificate issuance"); + } } const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; @@ -1334,37 +1495,7 @@ export const certificateV3ServiceFactory = ({ parseExtendedKeyUsages(originalCert.extendedKeyUsages) ), subjectAlternativeNames: originalCert.altNames - ? originalCert.altNames.split(",").map((san) => { - const trimmed = san.trim(); - - const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed); - const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed); - if (isIpv4 || isIpv6) { - return { - type: CertSubjectAlternativeNameType.IP_ADDRESS, - value: trimmed - }; - } - - if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) { - return { - type: CertSubjectAlternativeNameType.EMAIL, - value: trimmed - }; - } - - if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) { - return { - type: CertSubjectAlternativeNameType.URI, - value: trimmed - }; - } - - return { - type: CertSubjectAlternativeNameType.DNS_NAME, - value: trimmed - }; - }) + ? originalCert.altNames.split(",").map((san) => detectSanType(san.trim())) : [], validity: { ttl @@ -1408,41 +1539,64 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); } - validateAlgorithmCompatibility(ca, { - algorithms: template?.algorithms - } as { algorithms?: { signature?: string[] } }); + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; - const caResult = await internalCaService.issueCertFromCa({ - caId: ca.id, - friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", - commonName: originalCert.commonName || "", - altNames: originalCert.altNames || "", - ttl, - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter), - keyUsages: parseKeyUsages(originalCert.keyUsages), - extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), - signatureAlgorithm: originalSignatureAlgorithm, - keyAlgorithm: originalKeyAlgorithm, - isFromProfile: true, - actor, - actorId, - actorAuthMethod, - actorOrgId, - internal: true, - tx - }); - - certificate = caResult.certificate; - certificateChain = caResult.certificateChain; - issuingCaCertificate = caResult.issuingCaCertificate; - serialNumber = caResult.serialNumber; - - const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); - if (!foundCert) { - throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + // Only validate algorithm compatibility for internal CAs + if (caType === CaType.INTERNAL) { + validateAlgorithmCompatibility(ca, { + algorithms: template?.algorithms + } as { algorithms?: { signature?: string[] } }); + } + + if (caType === CaType.INTERNAL) { + // Internal CA renewal - existing logic + const caResult = await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", + commonName: originalCert.commonName || "", + altNames: originalCert.altNames || "", + ttl, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + isFromProfile: true, + actor, + actorId, + actorAuthMethod, + actorOrgId, + internal: true, + tx + }); + + certificate = caResult.certificate; + certificateChain = caResult.certificateChain; + issuingCaCertificate = caResult.issuingCaCertificate; + serialNumber = caResult.serialNumber; + + const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + if (!foundCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + newCert = foundCert; + } else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) { + // External CA renewal - mark for async processing outside transaction + return { + isExternalCA: true, + ca, + profile, + originalCert, + originalSignatureAlgorithm, + originalKeyAlgorithm, + ttl + }; + } else { + throw new BadRequestError({ + message: `CA type ${String(caType)} does not support certificate renewal` + }); } - newCert = foundCert; } else { // Self-signed certificate renewal const effectiveAlgorithms = getEffectiveAlgorithms( @@ -1522,6 +1676,48 @@ export const certificateV3ServiceFactory = ({ }; }); + // Handle external CA renewals separately + if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) { + const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult; + + const renewalOrderId = randomUUID(); + const altNamesArray = originalCert.altNames + ? originalCert.altNames.split(",").map((san: string) => san.trim()) + : []; + + await certificateIssuanceQueue.queueCertificateIssuance({ + certificateId: renewalOrderId, + profileId: profile?.id || "", + caId: ca.id, + commonName: originalCert.commonName || "", + altNames: altNamesArray, + ttl, + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + keyUsages: convertEnumsToStringArray(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)), + isRenewal: true, + originalCertificateId: certificateId, + certificateRequestId + }); + + return { + certificate: "", // External CA renewal is async + certificateChain: "", + issuingCaCertificate: "", + serialNumber: "", + certificateId: renewalOrderId, + projectId: originalCert.projectId, + profileName: profile?.slug || "External CA Profile", + commonName: originalCert.commonName || "" + }; + } + + // Type check to ensure we have internal CA renewal result + if ("isExternalCA" in renewalResult) { + throw new BadRequestError({ message: "External CA renewals should be handled asynchronously" }); + } + await triggerAutoSyncForCertificate(renewalResult.newCert.id, { certificateSyncDAL, pkiSyncDAL, diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index ab638c5ed..d448cb770 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -58,8 +58,10 @@ export type TOrderCertificateFromProfileDTO = { notAfter?: Date; signatureAlgorithm?: string; keyAlgorithm?: string; + template?: string; }; removeRootsFromChain?: boolean; + certificateRequestId?: string; } & Omit; export type TCertificateFromProfileResponse = { @@ -105,6 +107,7 @@ export type TCertificateOrderResponse = { export type TRenewCertificateDTO = { certificateId: string; removeRootsFromChain?: boolean; + certificateRequestId?: string; } & Omit; export type TUpdateRenewalConfigDTO = { diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 176a5dd9e..a4f623659 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -22,14 +22,25 @@ export type TCertificateProfile = { apiConfigId?: string; createdAt: string; updatedAt: string; + externalConfigs?: Record | null; + certificateAuthority?: { + id: string; + projectId?: string; + status: string; + name: string; + isExternal?: boolean; + externalType?: string | null; + }; }; export type TCertificateProfileWithDetails = TCertificateProfile & { certificateAuthority?: { id: string; - projectId: string; + projectId?: string; status: string; name: string; + isExternal?: boolean; + externalType?: string | null; }; certificateTemplate?: { id: string; @@ -72,6 +83,7 @@ export type TCreateCertificateProfileDTO = { renewBeforeDays?: number; }; acmeConfig?: unknown; + externalConfigs?: Record | null; }; export type TUpdateCertificateProfileDTO = { @@ -90,6 +102,7 @@ export type TUpdateCertificateProfileDTO = { renewBeforeDays?: number; }; acmeConfig?: unknown; + externalConfigs?: Record | null; }; export type TDeleteCertificateProfileDTO = { diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index bc7f80d2c..19882d8c3 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -7,4 +7,4 @@ export { useRevokeCert, useUpdateRenewalConfig } from "./mutations"; -export { useGetCert, useGetCertBody } from "./queries"; +export { useGetCert, useGetCertBody, useGetCertificateRequest } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index f65d6adc6..871d06bab 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -13,6 +13,8 @@ import { TRenewCertificateDTO, TRenewCertificateResponse, TRevokeCertDTO, + TUnifiedCertificateIssuanceDTO, + TUnifiedCertificateIssuanceResponse, TUpdateRenewalConfigDTO } from "./types"; @@ -185,3 +187,34 @@ export const useDownloadCertPkcs12 = () => { } }); }; + +export const useUnifiedCertificateIssuance = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { projectSlug, ...requestData } = body; + const { data } = await apiRequest.post( + "/api/v3/pki/certificates", + requestData, + { + params: { projectSlug } + } + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); + queryClient.invalidateQueries({ + queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 50f2836ed..9b6205e2d 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -2,12 +2,16 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificate } from "./types"; +import { TCertificate, TCertificateRequestDetails } from "./types"; export const certKeys = { getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"], - getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"] + getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"], + getCertificateRequest: (requestId: string, projectSlug: string) => [ + { requestId, projectSlug }, + "certificateRequest" + ] }; export const useGetCert = (serialNumber: string) => { @@ -55,3 +59,23 @@ export const useGetCertBundle = (serialNumber: string) => { enabled: Boolean(serialNumber) }); }; + +export const useGetCertificateRequest = (requestId: string, projectSlug: string) => { + return useQuery({ + queryKey: certKeys.getCertificateRequest(requestId, projectSlug), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v3/pki/certificates/requests/${requestId}`, + { + params: { projectSlug } + } + ); + return data; + }, + enabled: Boolean(requestId) && Boolean(projectSlug), + refetchInterval: (query) => { + // Only refetch if status is pending + return query.state.data?.status === "pending" ? 5000 : false; + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index ee543aac7..27cb060b3 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -64,6 +64,7 @@ export type TRenewCertificateResponse = { serialNumber: string; certificateId: string; projectId: string; + certificateRequestId?: string; }; export type TUpdateRenewalConfigDTO = { @@ -79,3 +80,51 @@ export type TDownloadPkcs12DTO = { password: string; alias: string; }; + +export type TUnifiedCertificateIssuanceDTO = { + projectSlug: string; + projectId: string; + profileId?: string; + caId?: string; + csr?: string; + commonName?: string; + altNames?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + notBefore?: Date; + notAfter?: Date; + keyAlgorithm?: string; + signatureAlgorithm?: string; + ttl?: string; + friendlyName?: string; + pkiCollectionId?: string; + issuerType?: string; +}; + +export type TUnifiedCertificateResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; +}; + +export type TCertificateRequestResponse = { + certificateRequestId: string; + status: "pending" | "issued" | "failed"; + projectId: string; +}; + +export type TUnifiedCertificateIssuanceResponse = + | TUnifiedCertificateResponse + | TCertificateRequestResponse; + +export type TCertificateRequestDetails = { + status: "pending" | "issued" | "failed"; + certificate: TCertificate | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 5f6462d2e..c544e2a66 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -20,9 +20,9 @@ import { } from "@app/components/v2"; import { useProject } from "@app/context"; import { useGetCert } from "@app/hooks/api"; -import { useCreateCertificateV3 } from "@app/hooks/api/ca"; import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; +import { useUnifiedCertificateIssuance } from "@app/hooks/api/certificates/mutations"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; @@ -103,10 +103,10 @@ type Props = { }; type TCertificateDetails = { - serialNumber: string; - certificate: string; - certificateChain: string; - privateKey: string; + serialNumber?: string; + certificate?: string; + certificateChain?: string; + privateKey?: string; }; export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { @@ -122,12 +122,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { data: profilesData } = useListCertificateProfiles({ projectId: currentProject?.id || "", - enrollmentType: EnrollmentType.API + enrollmentType: EnrollmentType.API, + includeConfigs: true }); - const { mutateAsync: createCertificate } = useCreateCertificateV3({ - projectId: currentProject?.id - }); + const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance(); const formResolver = useMemo(() => { return zodResolver(createSchema(shouldShowSubjectSection)); @@ -243,7 +242,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } keyUsages, extendedKeyUsages }: FormData) => { - if (!currentProject?.slug) { + if (!currentProject?.slug || !currentProject?.id) { createNotification({ text: "Project not found. Please refresh and try again.", type: "error" @@ -275,44 +274,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } } - const certificateRequest: any = { - profileId: formProfileId, - projectSlug: currentProject.slug, - ttl, - signatureAlgorithm, - keyAlgorithm, - keyUsages: filterUsages(keyUsages) as CertKeyUsage[], - extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] - }; + try { + // Prepare unified request + const request: any = { + profileId: formProfileId, + projectSlug: currentProject.slug, + projectId: currentProject.id, + ttl, + keyUsages: filterUsages(keyUsages) as CertKeyUsage[], + extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] + }; - if (constraints.shouldShowSubjectSection && commonName) { - certificateRequest.commonName = commonName; - } - if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { - const formattedSans = formatSubjectAltNames(subjectAltNames); - if (formattedSans && formattedSans.length > 0) { - certificateRequest.altNames = formattedSans; + if (constraints.shouldShowSubjectSection && commonName) { + request.commonName = commonName; } + + if (signatureAlgorithm) { + request.signatureAlgorithm = signatureAlgorithm; + } + + if (keyAlgorithm) { + request.keyAlgorithm = keyAlgorithm; + } + + if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { + const formattedSans = formatSubjectAltNames(subjectAltNames); + if (formattedSans && formattedSans.length > 0) { + request.altNames = formattedSans; + } + } + + const response = await issueCertificate(request); + + // Handle certificate issuance response + if ("certificate" in response) { + // Immediate certificate issuance + setCertificateDetails({ + serialNumber: response.serialNumber, + certificate: response.certificate, + certificateChain: response.certificateChain, + privateKey: response.privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } else { + // Certificate request - async processing + createNotification({ + text: `Certificate request submitted successfully. This may take a few minutes to process. Request ID: ${response.certificateRequestId}`, + type: "success" + }); + handlePopUpToggle("issueCertificate", false); + } + } catch (error) { + createNotification({ + text: `Failed to request certificate: ${(error as Error)?.message || "Unknown error"}`, + type: "error" + }); } - - const { serialNumber, certificate, certificateChain, privateKey } = - await createCertificate(certificateRequest); - - setCertificateDetails({ - serialNumber, - certificate, - certificateChain, - privateKey - }); - - createNotification({ - text: "Successfully created certificate", - type: "success" - }); }, [ currentProject?.slug, - createCertificate, + issueCertificate, constraints.shouldShowSubjectSection, constraints.shouldShowSanSection ] @@ -321,13 +346,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const getModalTitle = () => { if (certificateDetails) return "Certificate Created Successfully"; if (cert) return "Certificate Details"; - return "Issue New Certificate"; + return "Request New Certificate"; }; const getModalSubTitle = () => { if (certificateDetails) return "Certificate has been successfully created and is ready for use"; if (cert) return "View certificate information"; - return "Issue a new certificate using a certificate profile"; + return "Request a new certificate using a certificate profile"; }; return ( @@ -343,10 +368,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } {certificateDetails && ( )} {cert && ( @@ -498,7 +523,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } isLoading={isSubmitting} isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)} > - {cert ? "Update" : "Issue Certificate"} + {cert ? "Update" : "Request Certificate"} )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 044bc9cfc..edb16f519 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -9,6 +9,7 @@ import { createNotification } from "@app/components/notifications"; import { Button, Checkbox, + FilterableSelect, FormControl, Input, Modal, @@ -19,7 +20,8 @@ import { Tooltip } from "@app/components/v2"; import { useProject, useSubscription } from "@app/context"; -import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; +import { CaType } from "@app/hooks/api/ca/enums"; +import { useGetAzureAdcsTemplates, useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { EnrollmentType, IssuerType, @@ -77,7 +79,12 @@ const createSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z.object({}).optional(), + externalConfigs: z + .object({ + template: z.string().min(1, "Azure ADCS template is required") + }) + .optional() }) .refine( (data) => { @@ -212,7 +219,12 @@ const editSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z.object({}).optional(), + externalConfigs: z + .object({ + template: z.string().optional() + }) + .optional() }) .refine( (data) => { @@ -339,7 +351,7 @@ export const CreateProfileModal = ({ const { currentProject } = useProject(); const { subscription } = useSubscription(); - const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: allCaData } = useListCasByProjectId(currentProject?.id || ""); const { data: templateData } = useListCertificateTemplatesV2({ projectId: currentProject?.id || "", limit: 100, @@ -351,9 +363,23 @@ export const CreateProfileModal = ({ const isEdit = mode === "edit" && profile; - const certificateAuthorities = caData || []; + const certificateAuthorities = (allCaData || []).map((ca) => ({ + ...ca, + groupType: ca.type === "internal" ? "internal" : "external" + })); const certificateTemplates = templateData?.certificateTemplates || []; + const getGroupHeaderLabel = (groupType: "internal" | "external") => { + switch (groupType) { + case "internal": + return "Internal CAs"; + case "external": + return "External CAs"; + default: + return ""; + } + }; + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(isEdit ? editSchema : createSchema), defaultValues: isEdit @@ -380,7 +406,17 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + externalConfigs: profile.externalConfigs + ? { + template: + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ? profile.externalConfigs.template + : "" + } + : undefined } : { slug: "", @@ -393,15 +429,27 @@ export const CreateProfileModal = ({ autoRenew: false, renewBeforeDays: 30 }, - acmeConfig: {} + acmeConfig: {}, + externalConfigs: undefined } }); const watchedEnrollmentType = watch("enrollmentType"); const watchedIssuerType = watch("issuerType"); + const watchedCertificateAuthorityId = watch("certificateAuthorityId"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedAutoRenew = watch("apiConfig.autoRenew"); + // Get the selected CA to check if it's Azure ADCS + const selectedCa = certificateAuthorities.find((ca) => ca.id === watchedCertificateAuthorityId); + const isAzureAdcsCa = selectedCa?.type === CaType.AZURE_AD_CS; + + // Fetch Azure ADCS templates if needed + const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({ + caId: watchedCertificateAuthorityId || "", + projectId: currentProject?.id || "" + }); + useEffect(() => { if (isEdit && profile) { reset({ @@ -427,10 +475,38 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + externalConfigs: profile.externalConfigs + ? { + template: + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ? profile.externalConfigs.template + : "" + } + : undefined }); } - }, [isEdit, profile, reset]); + }, [isEdit, profile, reset, allCaData]); + + // Additional effect to reset external configs when Azure ADCS templates are loaded + useEffect(() => { + if ( + isEdit && + profile && + isAzureAdcsCa && + azureAdcsTemplatesData?.templates && + profile.externalConfigs && + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ) { + // Re-set the external configs to ensure the template value is properly set + // after the Azure ADCS templates have been loaded + setValue("externalConfigs.template", profile.externalConfigs.template); + } + }, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]); const onFormSubmit = async (data: FormData) => { if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { @@ -444,6 +520,18 @@ export const CreateProfileModal = ({ if (!currentProject?.id && !isEdit) return; + // Validate Azure ADCS template requirement + if ( + isAzureAdcsCa && + (!data.externalConfigs?.template || data.externalConfigs.template.trim() === "") + ) { + createNotification({ + text: "Azure ADCS Certificate Authority requires a template to be specified", + type: "error" + }); + return; + } + if (isEdit) { const updateData: TUpdateCertificateProfileDTO = { profileId: profile.id, @@ -460,6 +548,11 @@ export const CreateProfileModal = ({ updateData.acmeConfig = data.acmeConfig; } + // Add external configs if present + if (data.externalConfigs) { + updateData.externalConfigs = data.externalConfigs; + } + await updateProfile.mutateAsync(updateData); } else { if (!currentProject?.id) { @@ -491,6 +584,11 @@ export const CreateProfileModal = ({ createData.acmeConfig = data.acmeConfig; } + // Add external configs if present + if (data.externalConfigs) { + createData.externalConfigs = data.externalConfigs; + } + await createProfile.mutateAsync(createData); } @@ -587,30 +685,82 @@ export const CreateProfileModal = ({ ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + className="w-full" + /> + + )} + /> + )} + + {/* Azure ADCS Template Selection */} + {isAzureAdcsCa && ( + ( + + template.id === value) || + null + } + onChange={(selectedTemplate) => { + if (Array.isArray(selectedTemplate)) { + onChange(selectedTemplate[0]?.id || ""); + } else if ( + selectedTemplate && + typeof selectedTemplate === "object" && + "id" in selectedTemplate + ) { + onChange(selectedTemplate.id || ""); + } else { + onChange(""); + } + }} + getOptionLabel={(template) => template.name} + getOptionValue={(template) => template.id} + options={azureAdcsTemplatesData?.templates || []} + placeholder="Select an Azure ADCS certificate template" + className="w-full" + /> )} /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index c2ec7b688..1373c9fed 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { useCallback } from "react"; import { faCheck, @@ -49,7 +50,9 @@ export const ProfileRow = ({ }: Props) => { const { permission } = useProjectPermission(); - const { data: caData } = useGetCaById(profile.caId ?? ""); + const { data: caData } = useGetCaById( + profile.certificateAuthority?.isExternal ? "" : (profile.caId ?? "") + ); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const); @@ -120,11 +123,18 @@ export const ProfileRow = ({ {getEnrollmentTypeBadge(profile.enrollmentType)} - - {profile.issuerType === IssuerType.SELF_SIGNED - ? "Self-signed" - : caData?.friendlyName || caData?.commonName || profile.caId} - +
+ + {profile.issuerType === IssuerType.SELF_SIGNED + ? "Self-signed" + : profile.certificateAuthority?.isExternal + ? profile.certificateAuthority.name + : caData?.friendlyName || + caData?.commonName || + profile.certificateAuthority?.name || + profile.caId} + +
@@ -177,7 +187,7 @@ export const ProfileRow = ({ }} icon={} > - Issue Certificate + Request Certificate )} {canDeleteProfile && ( From 1145210145a0a445dc272f018d4f45f4a653554a Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 1 Dec 2025 10:16:21 -0300 Subject: [PATCH 2/8] Improve external configs schema --- .../routes/v1/certificate-profiles-router.ts | 19 +++---- ...ificate-profile-external-config-schemas.ts | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 371ee1d77..685050e5c 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -8,6 +8,7 @@ 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 { CertStatus } from "@app/services/certificate/certificate-types"; +import { ExternalConfigUnionSchema } from "@app/services/certificate-profile/certificate-profile-external-config-schemas"; import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { @@ -47,7 +48,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }) .optional(), acmeConfig: z.object({}).optional(), - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { @@ -151,7 +152,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -247,7 +248,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid directoryUrl: z.string() }) .optional(), - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }).array(), totalCount: z.number() }) @@ -293,7 +294,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }).extend({ certificateAuthority: z .object({ @@ -328,7 +329,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().optional() }) .optional(), - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -377,7 +378,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -433,7 +434,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { @@ -456,7 +457,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -503,7 +504,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ - externalConfigs: z.record(z.unknown()).nullable().optional() + externalConfigs: ExternalConfigUnionSchema }) }) } diff --git a/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts new file mode 100644 index 000000000..2d54d0d4f --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +/** + * External configuration schema for Azure AD CS Certificate Authority + */ +export const AzureAdCsExternalConfigSchema = z.object({ + template: z + .string() + .min(1, "Template name is required for Azure AD CS") + .describe("Certificate template name for Azure AD CS") +}); + +/** + * External configuration schema for ACME Certificate Authority + */ +export const AcmeExternalConfigSchema = z.object({}); + +/** + * Map of CA types to their corresponding external configuration schemas + */ +export const ExternalConfigSchemaMap = { + [CaType.AZURE_AD_CS]: AzureAdCsExternalConfigSchema, + [CaType.ACME]: AcmeExternalConfigSchema, + [CaType.INTERNAL]: z.object({}).optional() // Internal CAs don't use external configs +} as const; + +export const createExternalConfigSchema = (caType?: CaType | null) => { + if (!caType || caType === CaType.INTERNAL) { + return z.object({}).nullable().optional(); + } + + const schema = ExternalConfigSchemaMap[caType]; + if (!schema) { + return z.object({}).nullable().optional(); + } + + return schema.nullable().optional(); +}; + +/** + * Union type of all possible external configuration schemas + */ +export const ExternalConfigUnionSchema = z + .union([AzureAdCsExternalConfigSchema, AcmeExternalConfigSchema, z.object({})]) + .nullable() + .optional(); + +export type TExternalConfig = z.infer; From 95a26eca9bc24fc4ceb26399f845c3255370bd6c Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 1 Dec 2025 12:55:48 -0300 Subject: [PATCH 3/8] Fix router missmatch --- .../server/routes/v1/certificate-router.ts | 644 ++++++++++++++++-- .../v3/deprecated-certificates-router.ts | 406 +---------- .../src/hooks/api/certificates/mutations.tsx | 2 +- 3 files changed, 592 insertions(+), 460 deletions(-) diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index a4af7fd92..25284a7ac 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -2,9 +2,10 @@ import RE2 from "re2"; import { z } from "zod"; -import { CertificatesSchema } from "@app/db/schemas"; +import { CertificatesSchema, TCertificateRequests } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs"; +import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { addNoCacheHeaders } from "@app/server/lib/caching"; @@ -17,6 +18,7 @@ import { CertSignatureAlgorithm, CrlReason } from "@app/services/certificate/certificate-types"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -26,6 +28,7 @@ import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { booleanSchema } from "../sanitizedSchemas"; @@ -62,7 +65,397 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { return true; }; +const validateCertificateRequestFlow = (data: { + csr?: string; + subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>; + commonName?: string; + altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; +}) => { + const hasCSR = !!data.csr; + const hasSANs = !!data.subjectAlternativeNames?.length; + const hasStandardFields = !!(data.commonName || data.altNames?.length); + + const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields); + return flowCount === 1; +}; + export const registerCertificateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + projectId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + csr: z + .string() + .trim() + .min(1, "CSR cannot be empty") + .max(4096, "CSR cannot exceed 4096 characters") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }) + .refine(validateCertificateRequestFlow, { + message: + "Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)" + }), + response: { + 200: z.union([ + z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string(), + certificateRequestId: z.string() + }), + z.object({ + certificateRequestId: z.string(), + status: z.string(), + orderId: z.string().optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ) + .optional(), + authorizations: z + .array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ) + .optional(), + finalize: z.string().optional() + }) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { csr, subjectAlternativeNames, ...requestBody } = req.body; + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + profileId: requestBody.profileId, + csr, + commonName: requestBody.commonName, + altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + keyAlgorithm: requestBody.keyAlgorithm, + signatureAlgorithm: requestBody.signatureAlgorithm, + metadata: JSON.stringify({ + ttl: requestBody.ttl, + removeRootsFromChain: requestBody.removeRootsFromChain, + subjectAlternativeNames + }) + }); + + try { + if (csr) { + const extractedCsrData = extractCertificateRequestFromCSR(csr); + + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + csr, + validity: { ttl: requestBody.ttl }, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + enrollmentType: EnrollmentType.API, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: extractedCsrData.commonName || "" + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } + + const profile = await server.services.certificateProfile.getProfileById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId + }); + + let useOrderFlow = false; + if (profile?.caId) { + const ca = await server.services.certificateAuthority.getCaById({ + caId: profile.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; + useOrderFlow = caType !== CaType.INTERNAL; + } + + if (subjectAlternativeNames?.length || useOrderFlow) { + let acmeAltNames = subjectAlternativeNames; + if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) { + acmeAltNames = requestBody.altNames.map((alt) => ({ + type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME + ? ACMESANType.DNS + : ACMESANType.IP) as ACMESANType, + value: alt.value + })); + } + + const certificateOrderObject = { + altNames: acmeAltNames || [], + validity: { ttl: requestBody.ttl }, + commonName: requestBody.commonName, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + signatureAlgorithm: requestBody.signatureAlgorithm, + keyAlgorithm: requestBody.keyAlgorithm + }; + + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateOrder: certificateOrderObject, + removeRootsFromChain: requestBody.removeRootsFromChain, + certificateRequestId: certificateRequest.id + }); + + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.PENDING, + errorMessage: undefined + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return { + certificateRequestId: certificateRequest.id, + ...data + }; + } + const certificateRequestForService: CertificateRequestForService = { + commonName: requestBody.commonName, + keyUsages: requestBody.keyUsages, + extendedKeyUsages: requestBody.extendedKeyUsages, + altNames: requestBody.altNames, + validity: { ttl: requestBody.ttl }, + notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, + notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, + signatureAlgorithm: requestBody.signatureAlgorithm, + keyAlgorithm: requestBody.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + commonName: requestBody.commonName || "", + profileName: data.profileName + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } catch (error) { + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.FAILED, + errorMessage: error instanceof Error ? error.message : "Unknown error" + }); + throw error; + } + } + }); + server.route({ + method: "GET", + url: "/certificate-requests/:requestId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + requestId: z.string().uuid() + }), + query: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + status: z.nativeEnum(CertificateRequestStatus), + certificate: z.string().nullable(), + privateKey: z.string().nullable(), + serialNumber: z.string().nullable(), + errorMessage: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateRequest.getCertificateFromRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: (req.query as { projectId: string }).projectId, + certificateRequestId: req.params.requestId + }); + + if (data.certificate && data.serialNumber) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: (req.query as { projectId: string }).projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: req.params.requestId, + cn: "", + serialNumber: data.serialNumber || "" + } + } + }); + } + + return data; + } + }); + server.route({ method: "POST", url: "/issue-certificate", @@ -70,8 +463,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -111,7 +506,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, @@ -143,6 +539,28 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: req.body.keyAlgorithm, + signatureAlgorithm: req.body.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -157,7 +575,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -168,8 +589,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -196,14 +619,13 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { issuingCaCertificate: z.string().trim(), certificateChain: z.string().trim(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateRequest = extractCertificateRequestFromCSR(req.body.csr); - const data = await server.services.certificateV3.signCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, @@ -220,6 +642,31 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + csr: req.body.csr, + commonName: certificateRequestData.commonName, + altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: certificateRequestData.keyUsages, + extendedKeyUsages: certificateRequestData.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: certificateRequestData.keyAlgorithm, + signatureAlgorithm: certificateRequestData.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -229,12 +676,15 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: certificateRequest.commonName || "" + commonName: certificateRequestData.commonName || "" } } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -245,23 +695,23 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) - .min(1, "At least one subject alternative name must be provided"), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ), ttl: z .string() .trim() @@ -313,34 +763,54 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { }) ), finalize: z.string(), - certificate: z.string().optional() + certificate: z.string().optional(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const certificateOrderObject = { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + const data = await server.services.certificateV3.orderCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateOrder: { - altNames: req.body.subjectAlternativeNames, - validity: { - ttl: req.body.ttl - }, - commonName: req.body.commonName, - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm - }, + certificateOrder: certificateOrderObject, removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -354,7 +824,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -382,36 +855,87 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const data = await server.services.certificateV3.renewCertificate({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - certificateId: req.params.id, - removeRootsFromChain: req.body?.removeRootsFromChain - }); + let certificateRequest: TCertificateRequests | undefined; - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.RENEW_CERTIFICATE, - metadata: { - originalCertificateId: req.params.id, - newCertificateId: data.certificateId, - profileName: data.profileName, - commonName: data.commonName - } + try { + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); } - }); - return data; + certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: originalCertificate.cert.projectId, + profileId: originalCertificate.cert.profileId || undefined, + caId: originalCertificate.cert.caId ?? undefined, + metadata: JSON.stringify({ + operation: "renewal", + originalCertificateId: req.params.id, + removeRootsFromChain: req.body?.removeRootsFromChain + }) + }); + + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.id, + removeRootsFromChain: req.body?.removeRootsFromChain, + certificateRequestId: certificateRequest.id + }); + + if (data.certificate && data.certificate.trim() !== "") { + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId + }); + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.id, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return { + ...data, + certificateRequestId: certificateRequest.id + }; + } catch (error) { + if (certificateRequest) { + await server.services.certificateRequest.updateCertificateRequestStatus({ + certificateRequestId: certificateRequest.id, + status: CertificateRequestStatus.FAILED, + errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal" + }); + } + throw error; + } } }); diff --git a/backend/src/server/routes/v3/deprecated-certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts index 59a729505..74ea22604 100644 --- a/backend/src/server/routes/v3/deprecated-certificates-router.ts +++ b/backend/src/server/routes/v3/deprecated-certificates-router.ts @@ -5,7 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { @@ -14,7 +14,6 @@ import { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; -import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -61,398 +60,7 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { return true; }; -const validateCertificateRequestFlow = (data: { - csr?: string; - subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>; - commonName?: string; - altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; -}) => { - const hasCSR = !!data.csr; - const hasSANs = !!data.subjectAlternativeNames?.length; - const hasStandardFields = !!(data.commonName || data.altNames?.length); - - const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields); - return flowCount === 1; -}; - export const registerCertificatesRouter = async (server: FastifyZodProvider) => { - server.route({ - method: "POST", - url: "/", - config: { - rateLimit: writeLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificates], - body: z - .object({ - profileId: z.string().uuid(), - projectId: z.string().uuid(), - commonName: validateTemplateRegexField.optional(), - keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), - extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), - altNames: z - .array( - z.object({ - type: z.nativeEnum(CertSubjectAlternativeNameType), - value: z.string().min(1, "SAN value cannot be empty") - }) - ) - .optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), - csr: z - .string() - .trim() - .min(1, "CSR cannot be empty") - .max(4096, "CSR cannot exceed 4096 characters") - .optional(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) - .optional(), - ttl: z - .string() - .trim() - .min(1, "TTL cannot be empty") - .refine((val) => ms(val) > 0, "TTL must be a positive number"), - notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), - removeRootsFromChain: booleanSchema.default(false).optional() - }) - .refine(validateTtlAndDateFields, { - message: - "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." - }) - .refine(validateDateOrder, { - message: "notBefore must be earlier than notAfter" - }) - .refine(validateCertificateRequestFlow, { - message: - "Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)" - }), - response: { - 200: z.union([ - z.object({ - certificate: z.string().trim(), - issuingCaCertificate: z.string().trim(), - certificateChain: z.string().trim(), - privateKey: z.string().trim().optional(), - serialNumber: z.string().trim(), - certificateId: z.string(), - certificateRequestId: z.string() - }), - z.object({ - certificateRequestId: z.string(), - status: z.string(), - orderId: z.string().optional(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string(), - status: z.nativeEnum(CertificateOrderStatus) - }) - ) - .optional(), - authorizations: z - .array( - z.object({ - identifier: z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string() - }), - status: z.nativeEnum(CertificateOrderStatus), - expires: z.string().optional(), - challenges: z.array( - z.object({ - type: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - url: z.string(), - token: z.string() - }) - ) - }) - ) - .optional(), - finalize: z.string().optional() - }) - ]) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const { csr, subjectAlternativeNames, ...requestBody } = req.body; - - const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.body.projectId, - profileId: requestBody.profileId, - csr, - commonName: requestBody.commonName, - altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - keyAlgorithm: requestBody.keyAlgorithm, - signatureAlgorithm: requestBody.signatureAlgorithm, - metadata: JSON.stringify({ - ttl: requestBody.ttl, - removeRootsFromChain: requestBody.removeRootsFromChain, - subjectAlternativeNames - }) - }); - - try { - if (csr) { - const extractedCsrData = extractCertificateRequestFromCSR(csr); - - const data = await server.services.certificateV3.signCertificateFromProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId, - csr, - validity: { ttl: requestBody.ttl }, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - enrollmentType: EnrollmentType.API, - removeRootsFromChain: requestBody.removeRootsFromChain - }); - - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, - metadata: { - certificateProfileId: requestBody.profileId, - certificateId: data.certificateId, - profileName: data.profileName, - commonName: extractedCsrData.commonName || "" - } - } - }); - - return { - ...data, - certificateRequestId: certificateRequest.id - }; - } - - const profile = await server.services.certificateProfile.getProfileById({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId - }); - - let useOrderFlow = false; - if (profile?.caId) { - const ca = await server.services.certificateAuthority.getCaById({ - caId: profile.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); - const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; - useOrderFlow = caType !== CaType.INTERNAL; - } - - if (subjectAlternativeNames?.length || useOrderFlow) { - let acmeAltNames = subjectAlternativeNames; - if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) { - acmeAltNames = requestBody.altNames.map((alt) => ({ - type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME - ? ACMESANType.DNS - : ACMESANType.IP) as ACMESANType, - value: alt.value - })); - } - - const certificateOrderObject = { - altNames: acmeAltNames || [], - validity: { ttl: requestBody.ttl }, - commonName: requestBody.commonName, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - signatureAlgorithm: requestBody.signatureAlgorithm, - keyAlgorithm: requestBody.keyAlgorithm - }; - - const data = await server.services.certificateV3.orderCertificateFromProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId, - certificateOrder: certificateOrderObject, - removeRootsFromChain: requestBody.removeRootsFromChain, - certificateRequestId: certificateRequest.id - }); - - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.PENDING, - errorMessage: undefined - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, - metadata: { - certificateProfileId: requestBody.profileId, - orderId: data.orderId, - profileName: data.profileName - } - } - }); - - return { - certificateRequestId: certificateRequest.id, - ...data - }; - } - const certificateRequestForService: CertificateRequestForService = { - commonName: requestBody.commonName, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - altNames: requestBody.altNames, - validity: { ttl: requestBody.ttl }, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - signatureAlgorithm: requestBody.signatureAlgorithm, - keyAlgorithm: requestBody.keyAlgorithm - }; - - const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); - - const data = await server.services.certificateV3.issueCertificateFromProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId, - certificateRequest: mappedCertificateRequest, - removeRootsFromChain: requestBody.removeRootsFromChain - }); - - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, - metadata: { - certificateProfileId: requestBody.profileId, - certificateId: data.certificateId, - commonName: requestBody.commonName || "", - profileName: data.profileName - } - } - }); - - return { - ...data, - certificateRequestId: certificateRequest.id - }; - } catch (error) { - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.FAILED, - errorMessage: error instanceof Error ? error.message : "Unknown error" - }); - throw error; - } - } - }); - - server.route({ - method: "GET", - url: "/certificate-requests/:certificateRequestId/certificate", - config: { - rateLimit: readLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificates], - params: z.object({ - certificateRequestId: z.string().uuid() - }), - query: z.object({ - projectId: z.string().uuid() - }), - response: { - 200: z.object({ - status: z.nativeEnum(CertificateRequestStatus), - certificate: z.string().nullable(), - privateKey: z.string().nullable(), - serialNumber: z.string().nullable(), - errorMessage: z.string().nullable(), - createdAt: z.date(), - updatedAt: z.date() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const data = await server.services.certificateRequest.getCertificateFromRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: (req.query as { projectId: string }).projectId, - certificateRequestId: req.params.certificateRequestId - }); - - if (data.certificate && data.serialNumber) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: (req.query as { projectId: string }).projectId, - event: { - type: EventType.GET_CERT, - metadata: { - certId: req.params.certificateRequestId, - cn: "", - serialNumber: data.serialNumber || "" - } - } - }); - } - - return data; - } - }); - server.route({ method: "POST", url: "/issue-certificate", @@ -463,7 +71,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => hide: true, deprecated: true, tags: [ApiDocsTags.PkiCertificates], - description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -586,10 +194,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: true, // Hide deprecated endpoint from docs + hide: true, deprecated: true, tags: [ApiDocsTags.PkiCertificates], - description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -692,10 +300,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: true, // Hide deprecated endpoint from docs + hide: true, deprecated: true, tags: [ApiDocsTags.PkiCertificates], - description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -867,7 +475,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - serialNumber: req.params.certificateId + id: req.params.certificateId }); if (!originalCertificate) { throw new NotFoundError({ message: "Original certificate not found" }); diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index e64b87d5d..e3e6d6c36 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -194,7 +194,7 @@ export const useUnifiedCertificateIssuance = () => { mutationFn: async (body) => { const { projectSlug, ...requestData } = body; const { data } = await apiRequest.post( - "/api/v3/pki/certificates", + "/api/v1/cert-manager/certificates", requestData, { params: { projectSlug } From 443e4226fbee7cbe3adf08735943fa0b75a4958c Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 2 Dec 2025 02:11:12 -0300 Subject: [PATCH 4/8] Address PR comments and improvements --- .../ee/services/audit-log/audit-log-types.ts | 32 +- backend/src/server/routes/index.ts | 3 +- .../routes/v1/certificate-authority-router.ts | 19 +- .../routes/v1/certificate-profiles-router.ts | 2 +- .../server/routes/v1/certificate-router.ts | 700 ++++++------------ .../v1/deprecated-certificate-router.ts | 17 +- .../v3/deprecated-certificates-router.ts | 145 +--- .../certificate-issuance-queue.ts | 111 +-- .../internal-certificate-authority-service.ts | 21 +- .../internal-certificate-authority-types.ts | 17 + .../certificate-profile-types.ts | 2 +- .../certificate-request-dal.ts | 122 +-- .../certificate-request-service.test.ts | 12 +- .../certificate-request-service.ts | 34 +- .../certificate-v3-service.test.ts | 324 ++++---- .../certificate-v3/certificate-v3-service.ts | 421 +++++++---- .../certificate-v3/certificate-v3-types.ts | 28 +- frontend/src/hooks/api/certificates/types.ts | 50 +- .../components/CertificateIssuanceModal.tsx | 44 +- 19 files changed, 948 insertions(+), 1156 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 ac7aa6404..36b49a37c 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -388,6 +388,9 @@ export enum EventType { GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle", UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", + CREATE_CERTIFICATE_REQUEST = "create-certificate-request", + GET_CERTIFICATE_REQUEST = "get-certificate-request", + GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -2846,7 +2849,6 @@ interface OrderCertificateFromProfile { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; metadata: { certificateProfileId: string; - orderId: string; profileName: string; }; } @@ -4196,6 +4198,31 @@ interface DisableCertificateRenewalConfigEvent { }; } +interface CreateCertificateRequestEvent { + type: EventType.CREATE_CERTIFICATE_REQUEST; + metadata: { + certificateRequestId: string; + profileId?: string; + caId?: string; + commonName?: string; + }; +} + +interface GetCertificateRequestEvent { + type: EventType.GET_CERTIFICATE_REQUEST; + metadata: { + certificateRequestId: string; + }; +} + +interface GetCertificateFromRequestEvent { + type: EventType.GET_CERTIFICATE_FROM_REQUEST; + metadata: { + certificateRequestId: string; + certificateId?: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4575,6 +4602,9 @@ export type Event = | PamResourceDeleteEvent | UpdateCertificateRenewalConfigEvent | DisableCertificateRenewalConfigEvent + | CreateCertificateRequestEvent + | GetCertificateRequestEvent + | GetCertificateFromRequestEvent | AutomatedRenewCertificate | AutomatedRenewCertificateFailed | UserLoginEvent diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2ff71bc3a..41e7e76f7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2259,7 +2259,8 @@ export const registerRoutes = async ( kmsService, projectDAL, certificateBodyDAL, - certificateIssuanceQueue + certificateIssuanceQueue, + certificateRequestService }); const certificateV3Queue = certificateV3QueueServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 2917270ef..96e0db219 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -665,15 +665,16 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.internalCertificateAuthority.issueCertFromCa({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.body - }); + const response = await server.services.internalCertificateAuthority.issueCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = response; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 685050e5c..ff770326d 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -299,7 +299,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid certificateAuthority: z .object({ id: z.string(), - projectId: z.string().optional(), + projectId: z.string(), status: z.string(), name: z.string(), isExternal: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 25284a7ac..3823f791d 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -2,7 +2,7 @@ import RE2 from "re2"; import { z } from "zod"; -import { CertificatesSchema, TCertificateRequests } from "@app/db/schemas"; +import { CertificatesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs"; import { NotFoundError } from "@app/lib/errors"; @@ -13,7 +13,6 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ACMESANType, - CertificateOrderStatus, CertKeyAlgorithm, CertSignatureAlgorithm, CrlReason @@ -30,9 +29,21 @@ import { mapEnumsForValidation } from "@app/services/certificate-common/certific import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types"; import { booleanSchema } from "../sanitizedSchemas"; +type CertificateServiceResponse = TCertificateFromProfileResponse | Omit; + +const extractCertificateData = (data: CertificateServiceResponse) => ({ + certificate: data.certificate, + issuingCaCertificate: data.issuingCaCertificate, + certificateChain: data.certificateChain, + privateKey: "privateKey" in data ? data.privateKey : undefined, + serialNumber: data.serialNumber, + certificateId: data.certificateId +}); + interface CertificateRequestForService { commonName?: string; keyUsages?: CertKeyUsageType[]; @@ -50,13 +61,32 @@ interface CertificateRequestForService { keyAlgorithm?: string; } -const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { +const validateTtlAndDateFields = (data: { + attributes?: { notBefore?: string; notAfter?: string; ttl?: string }; + notBefore?: string; + notAfter?: string; + ttl?: string; +}) => { + if (data.attributes) { + const hasDateFields = data.attributes.notBefore || data.attributes.notAfter; + const hasTtl = data.attributes.ttl; + return !(hasDateFields && hasTtl); + } const hasDateFields = data.notBefore || data.notAfter; const hasTtl = data.ttl; return !(hasDateFields && hasTtl); }; -const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { +const validateDateOrder = (data: { + attributes?: { notBefore?: string; notAfter?: string }; + notBefore?: string; + notAfter?: string; +}) => { + if (data.attributes?.notBefore && data.attributes?.notAfter) { + const notBefore = new Date(data.attributes.notBefore); + const notAfter = new Date(data.attributes.notAfter); + return notBefore < notAfter; + } if (data.notBefore && data.notAfter) { const notBefore = new Date(data.notBefore); const notAfter = new Date(data.notAfter); @@ -65,20 +95,6 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { return true; }; -const validateCertificateRequestFlow = (data: { - csr?: string; - subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>; - commonName?: string; - altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; -}) => { - const hasCSR = !!data.csr; - const hasSANs = !!data.subjectAlternativeNames?.length; - const hasStandardFields = !!(data.commonName || data.altNames?.length); - - const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields); - return flowCount === 1; -}; - export const registerCertificateRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -92,45 +108,48 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { body: z .object({ profileId: z.string().uuid(), - projectId: z.string().uuid(), - commonName: validateTemplateRegexField.optional(), - keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), - extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), - altNames: z - .array( - z.object({ - type: z.nativeEnum(CertSubjectAlternativeNameType), - value: z.string().min(1, "SAN value cannot be empty") - }) - ) - .optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), csr: z .string() .trim() .min(1, "CSR cannot be empty") .max(4096, "CSR cannot exceed 4096 characters") .optional(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) + attributes: z + .object({ + commonName: validateTemplateRegexField.optional(), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional() + }) .optional(), - ttl: z - .string() - .trim() - .min(1, "TTL cannot be empty") - .refine((val) => ms(val) > 0, "TTL must be a positive number"), - notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { @@ -139,264 +158,177 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { }) .refine(validateDateOrder, { message: "notBefore must be earlier than notAfter" - }) - .refine(validateCertificateRequestFlow, { - message: - "Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)" }), response: { - 200: z.union([ - z.object({ - certificate: z.string().trim(), - issuingCaCertificate: z.string().trim(), - certificateChain: z.string().trim(), - privateKey: z.string().trim().optional(), - serialNumber: z.string().trim(), - certificateId: z.string(), - certificateRequestId: z.string() - }), - z.object({ - certificateRequestId: z.string(), - status: z.string(), - orderId: z.string().optional(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string(), - status: z.nativeEnum(CertificateOrderStatus) - }) - ) - .optional(), - authorizations: z - .array( - z.object({ - identifier: z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string() - }), - status: z.nativeEnum(CertificateOrderStatus), - expires: z.string().optional(), - challenges: z.array( - z.object({ - type: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - url: z.string(), - token: z.string() - }) - ) - }) - ) - .optional(), - finalize: z.string().optional() - }) - ]) + 200: z.object({ + certificate: z + .object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + .nullable() + .optional(), + certificateRequestId: z.string() + }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { csr, subjectAlternativeNames, ...requestBody } = req.body; - - const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + const { csr, attributes, ...requestBody } = req.body; + const profile = await server.services.certificateProfile.getProfileById({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.body.projectId, - profileId: requestBody.profileId, - csr, - commonName: requestBody.commonName, - altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - keyAlgorithm: requestBody.keyAlgorithm, - signatureAlgorithm: requestBody.signatureAlgorithm, - metadata: JSON.stringify({ - ttl: requestBody.ttl, - removeRootsFromChain: requestBody.removeRootsFromChain, - subjectAlternativeNames - }) + profileId: requestBody.profileId }); - try { - if (csr) { - const extractedCsrData = extractCertificateRequestFromCSR(csr); - - const data = await server.services.certificateV3.signCertificateFromProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId, - csr, - validity: { ttl: requestBody.ttl }, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - enrollmentType: EnrollmentType.API, - removeRootsFromChain: requestBody.removeRootsFromChain - }); - - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, - metadata: { - certificateProfileId: requestBody.profileId, - certificateId: data.certificateId, - profileName: data.profileName, - commonName: extractedCsrData.commonName || "" - } - } - }); - - return { - ...data, - certificateRequestId: certificateRequest.id - }; - } - - const profile = await server.services.certificateProfile.getProfileById({ + let useOrderFlow = false; + if (profile?.caId) { + const ca = await server.services.certificateAuthority.getCaById({ + caId: profile.caId, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId + actorOrgId: req.permission.orgId }); + const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; + useOrderFlow = caType !== CaType.INTERNAL; + } - let useOrderFlow = false; - if (profile?.caId) { - const ca = await server.services.certificateAuthority.getCaById({ - caId: profile.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); - const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; - useOrderFlow = caType !== CaType.INTERNAL; + if (attributes?.subjectAlternativeNames?.length || useOrderFlow) { + let acmeAltNames: Array<{ type: ACMESANType; value: string }> | undefined = attributes?.subjectAlternativeNames; + if (useOrderFlow && !attributes?.subjectAlternativeNames && attributes?.altNames?.length) { + acmeAltNames = attributes.altNames.map((alt: { type: CertSubjectAlternativeNameType; value: string }) => ({ + type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME + ? ACMESANType.DNS + : ACMESANType.IP) as ACMESANType, + value: alt.value + })); } - if (subjectAlternativeNames?.length || useOrderFlow) { - let acmeAltNames = subjectAlternativeNames; - if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) { - acmeAltNames = requestBody.altNames.map((alt) => ({ - type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME - ? ACMESANType.DNS - : ACMESANType.IP) as ACMESANType, - value: alt.value - })); - } - - const certificateOrderObject = { - altNames: acmeAltNames || [], - validity: { ttl: requestBody.ttl }, - commonName: requestBody.commonName, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - signatureAlgorithm: requestBody.signatureAlgorithm, - keyAlgorithm: requestBody.keyAlgorithm - }; - - const data = await server.services.certificateV3.orderCertificateFromProfile({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: requestBody.profileId, - certificateOrder: certificateOrderObject, - removeRootsFromChain: requestBody.removeRootsFromChain, - certificateRequestId: certificateRequest.id - }); - - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.PENDING, - errorMessage: undefined - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, - metadata: { - certificateProfileId: requestBody.profileId, - orderId: data.orderId, - profileName: data.profileName - } - } - }); - - return { - certificateRequestId: certificateRequest.id, - ...data - }; - } - const certificateRequestForService: CertificateRequestForService = { - commonName: requestBody.commonName, - keyUsages: requestBody.keyUsages, - extendedKeyUsages: requestBody.extendedKeyUsages, - altNames: requestBody.altNames, - validity: { ttl: requestBody.ttl }, - notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined, - notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined, - signatureAlgorithm: requestBody.signatureAlgorithm, - keyAlgorithm: requestBody.keyAlgorithm + const certificateOrderObject = { + altNames: acmeAltNames || [], + validity: { ttl: attributes?.ttl || "" }, + commonName: attributes?.commonName, + keyUsages: attributes?.keyUsages, + extendedKeyUsages: attributes?.extendedKeyUsages, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + signatureAlgorithm: attributes?.signatureAlgorithm, + keyAlgorithm: attributes?.keyAlgorithm, + csr }; - const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); - - const data = await server.services.certificateV3.issueCertificateFromProfile({ + const data = await server.services.certificateV3.orderCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: requestBody.profileId, - certificateRequest: mappedCertificateRequest, + certificateOrder: certificateOrderObject, removeRootsFromChain: requestBody.removeRootsFromChain }); - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, event: { - type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: requestBody.profileId, - certificateId: data.certificateId, - commonName: requestBody.commonName || "", profileName: data.profileName } } }); return { - ...data, - certificateRequestId: certificateRequest.id + certificate: null, + certificateRequestId: data.certificateRequestId }; - } catch (error) { - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.FAILED, - errorMessage: error instanceof Error ? error.message : "Unknown error" - }); - throw error; } + + if (csr) { + const extractedCsrData = extractCertificateRequestFromCSR(csr); + + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + csr, + validity: { ttl: attributes?.ttl || "" }, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + enrollmentType: EnrollmentType.API, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: extractedCsrData.commonName || "" + } + } + }); + return { + certificate: extractCertificateData(data), + certificateRequestId: data.certificateRequestId + }; + } + + const certificateRequestForService: CertificateRequestForService = { + commonName: attributes?.commonName, + keyUsages: attributes?.keyUsages, + extendedKeyUsages: attributes?.extendedKeyUsages, + altNames: attributes?.altNames, + validity: { ttl: attributes?.ttl || "" }, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + signatureAlgorithm: attributes?.signatureAlgorithm, + keyAlgorithm: attributes?.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + commonName: attributes?.commonName || "", + profileName: data.profileName + } + } + }); + return { + certificate: extractCertificateData(data), + certificateRequestId: data.certificateRequestId + }; } }); server.route({ @@ -437,21 +369,18 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { certificateRequestId: req.params.requestId }); - if (data.certificate && data.serialNumber) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: (req.query as { projectId: string }).projectId, - event: { - type: EventType.GET_CERT, - metadata: { - certId: req.params.requestId, - cn: "", - serialNumber: data.serialNumber || "" - } + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: (req.query as { projectId: string }).projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: req.params.requestId, + cn: "", + serialNumber: data.serialNumber || "" } - }); - } - + } + }); return data; } }); @@ -539,28 +468,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { removeRootsFromChain: req.body.removeRootsFromChain }); - const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: data.projectId, - profileId: req.body.profileId, - commonName: req.body.commonName, - altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","), - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - keyAlgorithm: req.body.keyAlgorithm, - signatureAlgorithm: req.body.signatureAlgorithm - }); - - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -575,10 +482,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { } }); - return { - ...data, - certificateRequestId: certificateRequest.id - }; + return data; } }); @@ -644,29 +548,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); - const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: data.projectId, - profileId: req.body.profileId, - csr: req.body.csr, - commonName: certificateRequestData.commonName, - altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), - keyUsages: certificateRequestData.keyUsages, - extendedKeyUsages: certificateRequestData.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - keyAlgorithm: certificateRequestData.keyAlgorithm, - signatureAlgorithm: certificateRequestData.signatureAlgorithm - }); - - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -681,10 +562,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { } }); - return { - ...data, - certificateRequestId: certificateRequest.id - }; + return data; } }); @@ -735,34 +613,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - orderId: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - subjectAlternativeNames: z.array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string(), - status: z.nativeEnum(CertificateOrderStatus) - }) - ), - authorizations: z.array( - z.object({ - identifier: z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string() - }), - status: z.nativeEnum(CertificateOrderStatus), - expires: z.string().optional(), - challenges: z.array( - z.object({ - type: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - url: z.string(), - token: z.string() - }) - ) - }) - ), - finalize: z.string(), certificate: z.string().optional(), certificateRequestId: z.string() }) @@ -794,23 +644,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { removeRootsFromChain: req.body.removeRootsFromChain }); - const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: data.projectId, - profileId: req.body.profileId, - commonName: req.body.commonName, - altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -818,16 +651,12 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, - orderId: data.orderId, profileName: data.profileName } } }); - return { - ...data, - certificateRequestId: certificateRequest.id - }; + return data; } }); @@ -862,80 +691,41 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - let certificateRequest: TCertificateRequests | undefined; - - try { - const originalCertificate = await server.services.certificate.getCert({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - id: req.params.id - }); - if (!originalCertificate) { - throw new NotFoundError({ message: "Original certificate not found" }); - } - - certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: originalCertificate.cert.projectId, - profileId: originalCertificate.cert.profileId || undefined, - caId: originalCertificate.cert.caId ?? undefined, - metadata: JSON.stringify({ - operation: "renewal", - originalCertificateId: req.params.id, - removeRootsFromChain: req.body?.removeRootsFromChain - }) - }); - - const data = await server.services.certificateV3.renewCertificate({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - certificateId: req.params.id, - removeRootsFromChain: req.body?.removeRootsFromChain, - certificateRequestId: certificateRequest.id - }); - - if (data.certificate && data.certificate.trim() !== "") { - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - } - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.RENEW_CERTIFICATE, - metadata: { - originalCertificateId: req.params.id, - newCertificateId: data.certificateId, - profileName: data.profileName, - commonName: data.commonName - } - } - }); - - return { - ...data, - certificateRequestId: certificateRequest.id - }; - } catch (error) { - if (certificateRequest) { - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.FAILED, - errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal" - }); - } - throw error; + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); } + + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.id, + removeRootsFromChain: req.body?.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.id, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return data; } }); diff --git a/backend/src/server/routes/v1/deprecated-certificate-router.ts b/backend/src/server/routes/v1/deprecated-certificate-router.ts index 955407e4c..73072ea31 100644 --- a/backend/src/server/routes/v1/deprecated-certificate-router.ts +++ b/backend/src/server/routes/v1/deprecated-certificate-router.ts @@ -242,14 +242,15 @@ export const registerDeprecatedCertRouter = async (server: FastifyZodProvider) = } }, handler: async (req) => { - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.internalCertificateAuthority.issueCertFromCa({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.body - }); + const response = await server.services.internalCertificateAuthority.issueCertFromCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = response; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v3/deprecated-certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts index 74ea22604..beaffd5f6 100644 --- a/backend/src/server/routes/v3/deprecated-certificates-router.ts +++ b/backend/src/server/routes/v3/deprecated-certificates-router.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { TCertificateRequests } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { NotFoundError } from "@app/lib/errors"; @@ -8,12 +7,7 @@ import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - ACMESANType, - CertificateOrderStatus, - CertKeyAlgorithm, - CertSignatureAlgorithm -} from "@app/services/certificate/certificate-types"; +import { ACMESANType, CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -23,7 +17,6 @@ import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; -import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { booleanSchema } from "../sanitizedSchemas"; @@ -340,34 +333,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - orderId: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - subjectAlternativeNames: z.array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string(), - status: z.nativeEnum(CertificateOrderStatus) - }) - ), - authorizations: z.array( - z.object({ - identifier: z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string() - }), - status: z.nativeEnum(CertificateOrderStatus), - expires: z.string().optional(), - challenges: z.array( - z.object({ - type: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - url: z.string(), - token: z.string() - }) - ) - }) - ), - finalize: z.string(), certificate: z.string().optional(), certificateRequestId: z.string() }) @@ -423,7 +388,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, - orderId: data.orderId, profileName: data.profileName } } @@ -467,80 +431,41 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - let certificateRequest: TCertificateRequests | undefined; - - try { - const originalCertificate = await server.services.certificate.getCert({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - id: req.params.certificateId - }); - if (!originalCertificate) { - throw new NotFoundError({ message: "Original certificate not found" }); - } - - certificateRequest = await server.services.certificateRequest.createCertificateRequest({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: originalCertificate.cert.projectId, - profileId: originalCertificate.cert.profileId || undefined, - caId: originalCertificate.cert.caId ?? undefined, - metadata: JSON.stringify({ - operation: "renewal", - originalCertificateId: req.params.certificateId, - removeRootsFromChain: req.body?.removeRootsFromChain - }) - }); - - const data = await server.services.certificateV3.renewCertificate({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - certificateId: req.params.certificateId, - removeRootsFromChain: req.body?.removeRootsFromChain, - certificateRequestId: certificateRequest.id - }); - - if (data.certificate && data.certificate.trim() !== "") { - await server.services.certificateRequest.attachCertificateToRequest({ - certificateRequestId: certificateRequest.id, - certificateId: data.certificateId - }); - } - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: data.projectId, - event: { - type: EventType.RENEW_CERTIFICATE, - metadata: { - originalCertificateId: req.params.certificateId, - newCertificateId: data.certificateId, - profileName: data.profileName, - commonName: data.commonName - } - } - }); - - return { - ...data, - certificateRequestId: certificateRequest.id - }; - } catch (error) { - if (certificateRequest) { - await server.services.certificateRequest.updateCertificateRequestStatus({ - certificateRequestId: certificateRequest.id, - status: CertificateRequestStatus.FAILED, - errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal" - }); - } - throw error; + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.certificateId + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); } + + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId, + removeRootsFromChain: req.body?.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.certificateId, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return data; } }); diff --git a/backend/src/services/certificate-authority/certificate-issuance-queue.ts b/backend/src/services/certificate-authority/certificate-issuance-queue.ts index 846d0c4bc..13fd68804 100644 --- a/backend/src/services/certificate-authority/certificate-issuance-queue.ts +++ b/backend/src/services/certificate-authority/certificate-issuance-queue.ts @@ -1,12 +1,11 @@ import acme from "acme-client"; -import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, TQueueServiceFactory } from "@app/queue"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -15,6 +14,7 @@ import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { CertKeyAlgorithm } from "../certificate-common/certificate-constants"; import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; import { CertificateRequestStatus } from "../certificate-request/certificate-request-types"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; @@ -41,6 +41,7 @@ export type TIssueCertificateFromProfileJobData = { isRenewal?: boolean; originalCertificateId?: string; certificateRequestId?: string; + csr?: string; }; type TCertificateIssuanceQueueFactoryDep = { @@ -86,40 +87,6 @@ export const certificateIssuanceQueueFactory = ({ certificateProfileDAL, certificateRequestService }: TCertificateIssuanceQueueFactoryDep) => { - const validateKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { - if (!keyUsages) return []; - const validKeyUsages = Object.values(CertKeyUsage); - - if (Array.isArray(keyUsages)) { - return keyUsages.filter( - (usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage) - ); - } - - return []; - }; - - const validateExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { - if (!extendedKeyUsages) return []; - const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage); - - if (Array.isArray(extendedKeyUsages)) { - return extendedKeyUsages.filter( - (usage): usage is CertExtendedKeyUsage => - typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage) - ); - } - - return []; - }; - - const validateKeyAlgorithm = (keyAlgorithm: unknown): CertKeyAlgorithm | undefined => { - if (typeof keyAlgorithm !== "string") return undefined; - const validKeyAlgorithms = Object.values(CertKeyAlgorithm); - return validKeyAlgorithms.includes(keyAlgorithm as CertKeyAlgorithm) - ? (keyAlgorithm as CertKeyAlgorithm) - : undefined; - }; const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, appConnectionService, @@ -168,7 +135,8 @@ export const certificateIssuanceQueueFactory = ({ extendedKeyUsages, isRenewal, originalCertificateId, - certificateRequestId + certificateRequestId, + csr }: TIssueCertificateFromProfileJobData) => { const jobData: TIssueCertificateFromProfileJobData = { certificateId, @@ -183,7 +151,8 @@ export const certificateIssuanceQueueFactory = ({ extendedKeyUsages, isRenewal, originalCertificateId, - certificateRequestId + certificateRequestId, + csr }; await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, { @@ -210,7 +179,8 @@ export const certificateIssuanceQueueFactory = ({ extendedKeyUsages, isRenewal, originalCertificateId, - certificateRequestId + certificateRequestId, + csr } = data; try { @@ -225,32 +195,36 @@ export const certificateIssuanceQueueFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (ca.externalCa?.type === CaType.ACME) { - const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm); - if (!validatedKeyAlgorithm) { - throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` }); - } - const keyAlg = keyAlgorithmToAlgCfg(validatedKeyAlgorithm); - const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]); - const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); - const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + let certificateCsr: string; + let skLeaf: string = ""; - const [, certificateCsr] = await acme.crypto.createCsr( - { - altNames: altNames || [], - commonName: commonName || "" - }, - skLeaf - ); + if (csr) { + certificateCsr = csr; + } else { + const keyAlg = keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const [, generatedCsr] = await acme.crypto.createCsr( + { + altNames: altNames || [], + commonName: commonName || "" + }, + skLeaf + ); + certificateCsr = generatedCsr.toString(); + } const acmeResult = await acmeFns.orderCertificateFromProfile({ caId, profileId, commonName: commonName || "", altNames: altNames || [], - csr: certificateCsr, + csr: Buffer.from(certificateCsr), csrPrivateKey: skLeaf, - keyUsages: validateKeyUsages(keyUsages), - extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages), + keyUsages: keyUsages as CertKeyUsage[], + extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[], ttl, signatureAlgorithm, keyAlgorithm, @@ -306,24 +280,20 @@ export const certificateIssuanceQueueFactory = ({ } } - const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm); - if (!validatedKeyAlgorithm) { - throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` }); - } - const azureParams = { caId, profileId, commonName: commonName || "", altNames: altNames || [], - keyUsages: validateKeyUsages(keyUsages), - extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages), + keyUsages: keyUsages as CertKeyUsage[], + extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[], validity: { ttl }, signatureAlgorithm, - keyAlgorithm: validatedKeyAlgorithm, + keyAlgorithm: keyAlgorithm as CertKeyAlgorithm, isRenewal, originalCertificateId, - template + template, + ...(csr && { csr }) }; const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams); @@ -383,8 +353,6 @@ export const certificateIssuanceQueueFactory = ({ }; const initializeCertificateIssuanceQueue = async () => { - const appCfg = getConfig(); - await queueService.startPg( QueueJobs.CaIssueCertificateFromProfile, async ([job]) => { @@ -392,8 +360,9 @@ export const certificateIssuanceQueueFactory = ({ await processCertificateIssuanceJobs(data); }, { - workerCount: appCfg.NODE_ENV === "production" ? 3 : 1, - batchSize: 1 + workerCount: 2, + batchSize: 1, + pollingIntervalSeconds: 1 } ); 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 35c596cd3..f3c56e322 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 @@ -67,6 +67,7 @@ import { TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TIssueCertFromCaResponse, TRenewCaCertDTO, TSignCertFromCaDTO, TSignIntermediateDTO, @@ -1198,7 +1199,7 @@ export const internalCertificateAuthorityServiceFactory = ({ isFromProfile, internal = false, tx - }: TIssueCertFromCaDTO) => { + }: TIssueCertFromCaDTO): Promise => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; let collectionId = pkiCollectionId; @@ -1532,10 +1533,11 @@ export const internalCertificateAuthorityServiceFactory = ({ return cert; }; + let cert; if (tx) { - await executeIssueCertOperations(tx); + cert = await executeIssueCertOperations(tx); } else { - await certificateDAL.transaction(executeIssueCertOperations); + cert = await certificateDAL.transaction(executeIssueCertOperations); } return { @@ -1544,6 +1546,8 @@ export const internalCertificateAuthorityServiceFactory = ({ issuingCaCertificate, privateKey: skLeaf, serialNumber, + certificateId: cert.id, + commonName, ca: expandInternalCa(ca) }; }; @@ -1905,8 +1909,8 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChainPem) }); - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( + const cert = await certificateDAL.transaction(async (tx) => { + const newCert = await certificateDAL.create( { caId: (ca as TCertificateAuthorities).id, caCertId: caCert.id, @@ -1929,7 +1933,7 @@ export const internalCertificateAuthorityServiceFactory = ({ await certificateBodyDAL.create( { - certId: cert.id, + certId: newCert.id, encryptedCertificate, encryptedCertificateChain }, @@ -1940,13 +1944,13 @@ export const internalCertificateAuthorityServiceFactory = ({ await pkiCollectionItemDAL.create( { pkiCollectionId: collectionId, - certId: cert.id + certId: newCert.id }, tx ); } - return cert; + return newCert; }); return { @@ -1954,6 +1958,7 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateChain: certificateChainPem, issuingCaCertificate, serialNumber, + certificateId: cert.id, ca: expandInternalCa(ca), commonName: cn }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index 8f4e9c0d6..60b9c65c5 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -248,3 +248,20 @@ export type TIssueCertWithTemplateDTO = { keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; }; + +type TCaReference = { + id: string; + projectId: string; + dn: string; +}; + +export type TIssueCertFromCaResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + privateKey: string; + serialNumber: string; + certificateId: string; + ca: TCaReference; + commonName: string; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 41c1b4746..3eca249cd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -56,7 +56,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { }; certificateAuthority?: { id: string; - projectId?: string; + projectId: string; status: string; name: string; isExternal?: boolean; diff --git a/backend/src/services/certificate-request/certificate-request-dal.ts b/backend/src/services/certificate-request/certificate-request-dal.ts index 93d13f0ba..df2a4b0c4 100644 --- a/backend/src/services/certificate-request/certificate-request-dal.ts +++ b/backend/src/services/certificate-request/certificate-request-dal.ts @@ -1,36 +1,12 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName, TCertificateRequests } from "@app/db/schemas"; +import { TableName, TCertificateRequests, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; - -type TCertificateRequestWithCertificateFlat = TCertificateRequests & { - certificateId?: string | null; - certificateSerialNumber?: string | null; - certificateFriendlyName?: string | null; - certificateCommonName?: string | null; - certificateAltNames?: string | null; - certificateStatus?: string | null; - certificateNotBefore?: Date | null; - certificateNotAfter?: Date | null; - certificateKeyUsages?: string[] | null; - certificateExtendedKeyUsages?: string[] | null; -}; - -type TCertificateInfo = { - id: string; - serialNumber: string; - friendlyName: string | null; - commonName: string; - altNames: string | null; - status: string; - notBefore: Date; - notAfter: Date; - keyUsages: string[] | null; - extendedKeyUsages: string[] | null; -}; +import { ormify, selectAllTableCols } from "@app/lib/knex"; type TCertificateRequestWithCertificate = TCertificateRequests & { - certificate: TCertificateInfo | null; + certificate: TCertificates | null; }; export type TCertificateRequestDALFactory = ReturnType; @@ -40,61 +16,24 @@ export const certificateRequestDALFactory = (db: TDbClient) => { const findByIdWithCertificate = async (id: string): Promise => { try { - const certificateRequest = (await db(TableName.CertificateRequests) - .leftJoin( - TableName.Certificate, - `${TableName.CertificateRequests}.certificateId`, - `${TableName.Certificate}.id` - ) - .where(`${TableName.CertificateRequests}.id`, id) - .select( - `${TableName.CertificateRequests}.*`, - `${TableName.Certificate}.id as certificateId`, - `${TableName.Certificate}.serialNumber as certificateSerialNumber`, - `${TableName.Certificate}.friendlyName as certificateFriendlyName`, - `${TableName.Certificate}.commonName as certificateCommonName`, - `${TableName.Certificate}.altNames as certificateAltNames`, - `${TableName.Certificate}.status as certificateStatus`, - `${TableName.Certificate}.notBefore as certificateNotBefore`, - `${TableName.Certificate}.notAfter as certificateNotAfter`, - `${TableName.Certificate}.keyUsages as certificateKeyUsages`, - `${TableName.Certificate}.extendedKeyUsages as certificateExtendedKeyUsages` - ) - .first()) as TCertificateRequestWithCertificateFlat | undefined; - + const certificateRequest = await certificateRequestOrm.findById(id); if (!certificateRequest) return null; - // Transform the flat result into nested structure - const { - certificateId, - certificateSerialNumber, - certificateFriendlyName, - certificateCommonName, - certificateAltNames, - certificateStatus, - certificateNotBefore, - certificateNotAfter, - certificateKeyUsages, - certificateExtendedKeyUsages, - ...certificateRequestData - } = certificateRequest; + if (!certificateRequest.certificateId) { + return { + ...certificateRequest, + certificate: null + }; + } + + const certificate = await db(TableName.Certificate) + .where("id", certificateRequest.certificateId) + .select(selectAllTableCols(TableName.Certificate)) + .first(); return { - ...certificateRequestData, - certificate: certificateId - ? { - id: certificateId, - serialNumber: certificateSerialNumber as string, - friendlyName: certificateFriendlyName || null, - commonName: certificateCommonName as string, - altNames: certificateAltNames || null, - status: certificateStatus as string, - notBefore: certificateNotBefore as Date, - notAfter: certificateNotAfter as Date, - keyUsages: certificateKeyUsages || null, - extendedKeyUsages: certificateExtendedKeyUsages || null - } - : null + ...certificateRequest, + certificate: certificate || null }; } catch (error) { throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" }); @@ -111,24 +50,33 @@ export const certificateRequestDALFactory = (db: TDbClient) => { } }; - const updateStatus = async (id: string, status: string, errorMessage?: string): Promise => { + const updateStatus = async ( + id: string, + status: string, + errorMessage?: string, + tx?: Knex + ): Promise => { try { const updateData: Partial = { status }; if (errorMessage !== undefined) { updateData.errorMessage = errorMessage; } - return await certificateRequestOrm.updateById(id, updateData); + return await certificateRequestOrm.updateById(id, updateData, tx); } catch (error) { throw new DatabaseError({ error, name: "Update certificate request status" }); } }; - const attachCertificate = async (id: string, certificateId: string): Promise => { + const attachCertificate = async (id: string, certificateId: string, tx?: Knex): Promise => { try { - return await certificateRequestOrm.updateById(id, { - certificateId, - status: "issued" - }); + return await certificateRequestOrm.updateById( + id, + { + certificateId, + status: "issued" + }, + tx + ); } catch (error) { throw new DatabaseError({ error, name: "Attach certificate to request" }); } diff --git a/backend/src/services/certificate-request/certificate-request-service.test.ts b/backend/src/services/certificate-request/certificate-request-service.test.ts index fec4ce9ae..2df3a2354 100644 --- a/backend/src/services/certificate-request/certificate-request-service.test.ts +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -13,7 +13,7 @@ import { ProjectPermissionSet, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; @@ -202,7 +202,7 @@ describe("CertificateRequestService", () => { (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); - await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(BadRequestError); + await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError); }); }); @@ -257,14 +257,14 @@ describe("CertificateRequestService", () => { "550e8400-e29b-41d4-a716-446655440005" ); expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ - serialNumber: "123456", + id: "550e8400-e29b-41d4-a716-446655440006", actor: ActorType.USER, actorId: "550e8400-e29b-41d4-a716-446655440001", actorAuthMethod: AuthMethod.EMAIL, actorOrgId: "550e8400-e29b-41d4-a716-446655440002" }); expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ - serialNumber: "123456", + id: "550e8400-e29b-41d4-a716-446655440006", actor: ActorType.USER, actorId: "550e8400-e29b-41d4-a716-446655440001", actorAuthMethod: AuthMethod.EMAIL, @@ -354,14 +354,14 @@ describe("CertificateRequestService", () => { "550e8400-e29b-41d4-a716-446655440005" ); expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ - serialNumber: "123456", + id: "550e8400-e29b-41d4-a716-446655440008", actor: ActorType.USER, actorId: "550e8400-e29b-41d4-a716-446655440001", actorAuthMethod: AuthMethod.EMAIL, actorOrgId: "550e8400-e29b-41d4-a716-446655440002" }); expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ - serialNumber: "123456", + id: "550e8400-e29b-41d4-a716-446655440008", actor: ActorType.USER, actorId: "550e8400-e29b-41d4-a716-446655440001", actorAuthMethod: AuthMethod.EMAIL, diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts index b27783822..67cd6d77c 100644 --- a/backend/src/services/certificate-request/certificate-request-service.ts +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; import { z } from "zod"; import { ActionProjectType } from "@app/db/schemas"; @@ -30,13 +31,12 @@ type TCertificateRequestServiceFactoryDep = { export type TCertificateRequestServiceFactory = ReturnType; -// Input validation schemas const certificateRequestDataSchema = z .object({ profileId: z.string().uuid().optional(), caId: z.string().uuid().optional(), csr: z.string().min(1).optional(), - commonName: z.string().min(1).max(255).optional(), + commonName: z.string().max(255).optional(), altNames: z.string().max(1000).optional(), keyUsages: z.array(z.string()).max(20).optional(), extendedKeyUsages: z.array(z.string()).max(20).optional(), @@ -93,8 +93,9 @@ export const certificateRequestServiceFactory = ({ actorAuthMethod, actorOrgId, projectId, + tx, ...requestData - }: TCreateCertificateRequestDTO) => { + }: TCreateCertificateRequestDTO & { tx?: Knex }) => { const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -112,11 +113,20 @@ export const certificateRequestServiceFactory = ({ // Validate input data before creating the request const validatedData = validateCertificateRequestData(requestData); - const certificateRequest = await certificateRequestDAL.create({ - status: CertificateRequestStatus.PENDING, - projectId, - ...validatedData - }); + const certificateRequest = tx + ? await certificateRequestDAL.create( + { + status: CertificateRequestStatus.PENDING, + projectId, + ...validatedData + }, + tx + ) + : await certificateRequestDAL.create({ + status: CertificateRequestStatus.PENDING, + projectId, + ...validatedData + }); return certificateRequest; }; @@ -149,7 +159,7 @@ export const certificateRequestServiceFactory = ({ } if (certificateRequest.projectId !== projectId) { - throw new BadRequestError({ message: "Certificate request does not belong to this project" }); + throw new NotFoundError({ message: "Certificate request not found" }); } return certificateRequest; @@ -183,7 +193,7 @@ export const certificateRequestServiceFactory = ({ } if (certificateRequest.projectId !== projectId) { - throw new BadRequestError({ message: "Certificate request does not belong to this project" }); + throw new NotFoundError({ message: "Certificate request not found" }); } // If no certificate is attached, return basic info @@ -201,7 +211,7 @@ export const certificateRequestServiceFactory = ({ // Get certificate body (PEM data) const certBody = await certificateService.getCertBody({ - serialNumber: certificateRequest.certificate.serialNumber, + id: certificateRequest.certificate.id, actor, actorId, actorAuthMethod, @@ -212,7 +222,7 @@ export const certificateRequestServiceFactory = ({ let privateKey: string | null = null; try { const certPrivateKey = await certificateService.getCertPrivateKey({ - serialNumber: certificateRequest.certificate.serialNumber, + id: certificateRequest.certificate.id, actor, actorId, actorAuthMethod, diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index bab58385f..b3db6f736 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -11,7 +11,7 @@ import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-ac import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; -import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; +import { ACMESANType, CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; @@ -198,6 +198,9 @@ describe("CertificateV3Service", () => { } as any, certificateIssuanceQueue: { queueCertificateIssuance: vi.fn().mockResolvedValue(undefined) + }, + certificateRequestService: { + createCertificateRequest: vi.fn().mockResolvedValue({ id: "cert-req-123" }) } }); }); @@ -287,6 +290,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: { id: "ca-123", projectId: "project-123", @@ -346,8 +351,13 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + const result = await service.issueCertificateFromProfile({ profileId, certificateRequest: mockCertificateRequest, @@ -481,6 +491,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: { id: "ca-123", projectId: "project-123", @@ -523,8 +535,34 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + await service.issueCertificateFromProfile({ profileId, certificateRequest: camelCaseRequest, @@ -749,8 +787,13 @@ describe("CertificateV3Service", () => { }); vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + const result = await service.signCertificateFromProfile({ profileId, csr: mockCSR, @@ -819,168 +862,6 @@ describe("CertificateV3Service", () => { keyAlgorithm: "RSA_2048" }; - it("should create order successfully for API enrollment profile", async () => { - const profileId = "profile-123"; - const mockProfile = { - id: profileId, - projectId: "project-123", - enrollmentType: EnrollmentType.API, - issuerType: IssuerType.CA, - caId: "ca-123", - certificateTemplateId: "template-123", - createdAt: new Date(), - updatedAt: new Date(), - slug: "test-profile-order", - description: "Test order profile", - estConfigId: null, - apiConfigId: null - }; - - const mockCA = { - id: "ca-123", - projectId: "project-123", - externalCa: undefined, - internalCa: { - id: "internal-ca-123", - parentCaId: null, - type: "ROOT", - friendlyName: "Test CA", - organization: "Test Org", - ou: "Test OU", - country: "US", - province: "CA", - locality: "SF", - commonName: "Test CA", - dn: "CN=Test CA", - serialNumber: "123", - maxPathLength: null, - keyAlgorithm: "RSA_2048", - notBefore: undefined, - notAfter: undefined, - activeCaCertId: "cert-123", - caId: "ca-123" - }, - name: "Test CA", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - enableDirectIssuance: true - }; - - const mockTemplate = { - id: "template-123", - name: "Test Order Template", - createdAt: new Date(), - updatedAt: new Date(), - projectId: "project-123", - description: "Test template for ordering certificates", - signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, - keyAlgorithm: { defaultKeyType: "RSA_2048" }, - attributes: [ - { - type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL, - value: ["example.com"] - } - ], - subject: undefined, - sans: undefined, - keyUsages: undefined, - extendedKeyUsages: undefined, - algorithms: undefined, - validity: undefined - }; - - const mockCertificateResult = { - certificate: "cert", - certificateChain: "chain", - issuingCaCertificate: "issuing-ca", - privateKey: "key", - serialNumber: "123456", - ca: { - id: "ca-123", - projectId: "project-123", - name: "Test CA", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - enableDirectIssuance: true, - externalCa: undefined, - internalCa: { - id: "internal-ca-123", - parentCaId: null, - type: "ROOT", - friendlyName: "Test CA", - organization: "Test Org", - ou: "Test OU", - country: "US", - province: "CA", - locality: "SF", - commonName: "Test CA", - dn: "CN=Test CA", - serialNumber: "123", - maxPathLength: null, - keyAlgorithm: "RSA_2048", - notBefore: null, - notAfter: null, - activeCaCertId: "cert-123", - caId: "ca-123" - } - } - }; - - const mockCertRecord = { - id: "cert-123", - serialNumber: "123456", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - projectId: "project-123", - commonName: "example.com", - friendlyName: "Test Order Cert", - notBefore: new Date(), - notAfter: new Date(), - caId: "ca-123", - certificateTemplateId: "template-123", - revokedAt: null, - altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), - caCertId: null, - keyUsages: ["DIGITAL_SIGNATURE"], - extendedKeyUsages: ["SERVER_AUTH"], - revocationReason: null, - pkiSubscriberId: null, - profileId: null - }; - - vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); - vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ - isValid: true, - errors: [], - warnings: [] - }); - vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); - vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); - vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); - vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); - vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); - - const result = await service.orderCertificateFromProfile({ - profileId, - certificateOrder: mockCertificateOrder, - ...mockActor - }); - - expect(result).toHaveProperty("orderId"); - expect(result).toHaveProperty("status", "valid"); - expect(result).toHaveProperty("certificate"); - expect(result.subjectAlternativeNames).toHaveLength(1); - expect(result.subjectAlternativeNames[0]).toEqual({ - type: ACMESANType.DNS, - value: "example.com", - status: CertificateOrderStatus.VALID - }); - }); - it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { const profileId = "profile-123"; const mockProfile = { @@ -1117,6 +998,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: rsaCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1163,6 +1046,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - RSA CA is compatible with RSA signature algorithms await expect( @@ -1249,6 +1157,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: ecCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1295,6 +1205,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - EC CA is compatible with ECDSA signature algorithms await expect( @@ -1381,6 +1316,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: rsa8192Ca as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1427,6 +1364,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - dynamic check supports new RSA key sizes await expect( @@ -1513,6 +1475,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: newEcCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1559,6 +1523,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - dynamic check supports new EC curves await expect( @@ -1692,8 +1681,9 @@ describe("CertificateV3Service", () => { }); it("should successfully renew eligible certificate", async () => { - // Mock the initial findById call - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateDAL.findById) + .mockResolvedValueOnce(mockOriginalCert) + .mockResolvedValueOnce({ ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }); vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); @@ -1709,6 +1699,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); @@ -2026,6 +2018,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 21a24a207..21e315a19 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -20,7 +20,6 @@ import { TCertificateDALFactory } from "@app/services/certificate/certificate-da import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { CertExtendedKeyUsage, - CertificateOrderStatus, CertKeyAlgorithm, CertKeyType, CertKeyUsage, @@ -40,11 +39,7 @@ import { } from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { - EnrollmentType, - IssuerType, - TCertificateProfileWithConfigs -} from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -71,7 +66,9 @@ import { normalizeDateForApi, removeRootCaFromChain } from "../certificate-common/certificate-utils"; +import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils"; @@ -122,6 +119,7 @@ type TCertificateV3ServiceFactoryDep = { import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory, "queueCertificateIssuance" >; + certificateRequestService: Pick; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -861,7 +859,8 @@ export const certificateV3ServiceFactory = ({ pkiSyncQueue, kmsService, projectDAL, - certificateIssuanceQueue + certificateIssuanceQueue, + certificateRequestService }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -945,7 +944,7 @@ export const certificateV3ServiceFactory = ({ const result = await certificateDAL.transaction(async (tx) => { const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); - return processSelfSignedCertificate({ + const selfSignedResult = await processSelfSignedCertificate({ certificateRequest, template, profile, @@ -957,9 +956,29 @@ export const certificateV3ServiceFactory = ({ projectDAL, tx }); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + profileId: profile.id, + commonName: certificateRequest.commonName, + altNames: certificateRequest.altNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages), + notBefore: certificateRequest.notBefore, + notAfter: certificateRequest.notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm + }); + + return { ...selfSignedResult, certificateRequestId: certRequestResult.id }; }); - const { selfSignedResult, certificateData } = result; + const { selfSignedResult, certificateData, certificateRequestId } = result; const subjectCommonName = (selfSignedResult.certificateSubject.common_name as string) || @@ -985,6 +1004,7 @@ export const certificateV3ServiceFactory = ({ privateKey: selfSignedResult.privateKey.toString("utf8"), serialNumber: selfSignedResult.serialNumber, certificateId: certificateData.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: subjectCommonName @@ -1003,8 +1023,16 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "direct certificate issuance"); validateAlgorithmCompatibility(ca, template); - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = - await internalCaService.issueCertFromCa({ + const { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber, + cert, + certificateRequestId + } = await certificateDAL.transaction(async (tx) => { + const certResult = await internalCaService.issueCertFromCa({ caId: ca.id, friendlyName: certificateSubject.common_name || "Certificate", commonName: certificateSubject.common_name || "", @@ -1023,22 +1051,43 @@ export const certificateV3ServiceFactory = ({ isFromProfile: true }); - const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); - if (!cert) { - throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); - } + const certificateRecord = await certificateDAL.findById(certResult.certificateId); + if (!certificateRecord) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } - const finalRenewBeforeDays = calculateFinalRenewBeforeDays( - profile, - certificateRequest.validity.ttl, - new Date(cert.notAfter) - ); + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + new Date(certificateRecord.notAfter) + ); - const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; - if (finalRenewBeforeDays !== undefined) { - updateData.renewBeforeDays = finalRenewBeforeDays; - } - await certificateDAL.updateById(cert.id, updateData); + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(certificateRecord.id, updateData); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + profileId: profile.id, + commonName: certificateRequest.commonName, + altNames: certificateRequest.altNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages), + notBefore: certificateRequest.notBefore, + notAfter: certificateRequest.notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm + }); + + return { ...certResult, cert: certificateRecord, certificateRequestId: certRequestResult.id }; + }); let finalCertificateChain = bufferToString(certificateChain); if (removeRootsFromChain) { @@ -1052,6 +1101,7 @@ export const certificateV3ServiceFactory = ({ privateKey: bufferToString(privateKey), serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -1135,33 +1185,60 @@ export const certificateV3ServiceFactory = ({ const effectiveSignatureAlgorithm = extractedSignatureAlgorithm; const effectiveKeyAlgorithm = extractedKeyAlgorithm; - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.signCertFromCa({ - isInternal: true, - caId: ca.id, - csr, - ttl: validity.ttl, - altNames: undefined, - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter), - signatureAlgorithm: effectiveSignatureAlgorithm, - keyAlgorithm: effectiveKeyAlgorithm, - isFromProfile: true + const { certificate, certificateChain, issuingCaCertificate, serialNumber, cert, certificateRequestId } = + await certificateDAL.transaction(async (tx) => { + const certResult = await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true + }); + + const signedCertRecord = await certificateDAL.findById(certResult.certificateId); + if (!signedCertRecord) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + validity.ttl, + new Date(signedCertRecord.notAfter) + ); + + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(signedCertRecord.id, updateData); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + profileId: profile.id, + csr, + commonName: mappedCertificateRequest.commonName, + altNames: mappedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(mappedCertificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(mappedCertificateRequest.extendedKeyUsages), + notBefore, + notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm + }); + + return { ...certResult, cert: signedCertRecord, certificateRequestId: certRequestResult.id }; }); - const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); - if (!cert) { - throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); - } - - const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); - - const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId }; - if (finalRenewBeforeDays !== undefined) { - updateData2.renewBeforeDays = finalRenewBeforeDays; - } - await certificateDAL.updateById(cert.id, updateData2); - const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); if (removeRootsFromChain) { @@ -1174,6 +1251,7 @@ export const certificateV3ServiceFactory = ({ certificateChain: certificateChainString, serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -1186,9 +1264,7 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId, - removeRootsFromChain, - certificateRequestId + actorOrgId }: TOrderCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -1202,37 +1278,59 @@ export const certificateV3ServiceFactory = ({ EnrollmentType.API ); - const certificateRequest = { - commonName: certificateOrder.commonName, - keyUsages: certificateOrder.keyUsages, - extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames?.map((san) => { - let certType: CertSubjectAlternativeNameType; - switch (san.type) { - case "dns": - certType = CertSubjectAlternativeNameType.DNS_NAME; - break; - case "ip": - certType = CertSubjectAlternativeNameType.IP_ADDRESS; - break; - default: - throw new BadRequestError({ - message: `Unsupported Subject Alternative Name type: ${san.type as string}` - }); - } - return { - type: certType, - value: san.value - }; - }), - validity: certificateOrder.validity, - notBefore: certificateOrder.notBefore, - notAfter: certificateOrder.notAfter, - signatureAlgorithm: certificateOrder.signatureAlgorithm, - keyAlgorithm: certificateOrder.keyAlgorithm - }; + let certificateRequest: TCertificateRequest; + let extractedKeyAlgorithm: string | undefined; + let extractedSignatureAlgorithm: string | undefined; + + if (certificateOrder.csr) { + certificateRequest = extractCertificateRequestFromCSR(certificateOrder.csr); + const algorithms = extractAlgorithmsFromCSR(certificateOrder.csr); + extractedKeyAlgorithm = algorithms.keyAlgorithm; + extractedSignatureAlgorithm = algorithms.signatureAlgorithm; + certificateRequest.validity = certificateOrder.validity; + if (certificateOrder.notBefore && certificateOrder.notAfter) { + certificateRequest.notBefore = certificateOrder.notBefore; + certificateRequest.notAfter = certificateOrder.notAfter; + } + } else { + certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames?.map((san) => { + let certType: CertSubjectAlternativeNameType; + switch (san.type) { + case "dns": + certType = CertSubjectAlternativeNameType.DNS_NAME; + break; + case "ip": + certType = CertSubjectAlternativeNameType.IP_ADDRESS; + break; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + return { + type: certType, + value: san.value + }; + }), + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + } const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + + if (certificateOrder.csr) { + mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm; + mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, mappedCertificateRequest @@ -1258,64 +1356,53 @@ export const certificateV3ServiceFactory = ({ const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; if (caType === CaType.INTERNAL) { - const certificateResult = await issueCertificateFromProfile({ - profileId, - certificateRequest, - actor, - actorId, - actorAuthMethod, - actorOrgId, - removeRootsFromChain + throw new BadRequestError({ + message: "Certificate ordering is not supported for the specified CA type" }); - - const orderId = randomUUID(); - - return { - orderId, - status: CertificateOrderStatus.VALID, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type, - value: san.value, - status: CertificateOrderStatus.VALID - })), - authorizations: [], - finalize: `/api/v1/cert-manager/certificates/orders/${orderId}/completed`, - certificate: certificateResult.certificate, - projectId: certificateResult.projectId, - profileName: certificateResult.profileName - }; } if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) { const orderId = randomUUID(); + const certRequest = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + profileId: profile.id, + commonName: certificateOrder.commonName || "", + keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [], + extendedKeyUsages: certificateOrder.extendedKeyUsages + ? convertEnumsToStringArray(certificateOrder.extendedKeyUsages) + : [], + keyAlgorithm: certificateOrder.keyAlgorithm || "", + signatureAlgorithm: certificateOrder.signatureAlgorithm || "", + altNames: certificateOrder.altNames?.map((san) => san.value).join(",") || "", + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter + }); + await certificateIssuanceQueue.queueCertificateIssuance({ certificateId: orderId, profileId: profile.id, caId: profile.caId || "", ttl: certificateOrder.validity?.ttl || "1y", signatureAlgorithm: certificateOrder.signatureAlgorithm || "", - keyAlgorithm: certificateOrder.keyAlgorithm || "", - commonName: certificateOrder.commonName || "", - altNames: certificateOrder.altNames?.map((san) => san.value) || [], - keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [], - extendedKeyUsages: certificateOrder.extendedKeyUsages - ? convertEnumsToStringArray(certificateOrder.extendedKeyUsages) + keyAlgorithm: certificateRequest.keyAlgorithm || "", + commonName: certificateRequest.commonName || "", + altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value) || [], + keyUsages: certificateRequest.keyUsages ? convertEnumsToStringArray(certificateRequest.keyUsages) : [], + extendedKeyUsages: certificateRequest.extendedKeyUsages + ? convertEnumsToStringArray(certificateRequest.extendedKeyUsages) : [], - certificateRequestId + certificateRequestId: certRequest.id, + csr: certificateOrder.csr }); return { - orderId, - status: CertificateOrderStatus.PENDING, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type, - value: san.value, - status: CertificateOrderStatus.PENDING - })), - authorizations: [], - finalize: `/api/v3/pki/certificates/orders/${orderId}/finalize`, - projectId: profile.projectId, + certificateRequestId: certRequest.id, + projectId: certRequest.projectId, profileName: profile.slug }; } @@ -1325,30 +1412,6 @@ export const certificateV3ServiceFactory = ({ }); }; - // Type for internal CA renewal result - type TInternalRenewalData = { - certificate: string; - certificateChain: string; - issuingCaCertificate: string; - serialNumber: string; - newCert: TCertificates; - originalCert: TCertificates; - profile: TCertificateProfileWithConfigs | null; - }; - - // Type for external CA renewal result - type TExternalRenewalData = { - isExternalCA: true; - ca: TCertificateAuthorityWithAssociatedCa; - profile: TCertificateProfileWithConfigs | null; - originalCert: TCertificates; - originalSignatureAlgorithm: CertSignatureAlgorithm; - originalKeyAlgorithm: CertKeyAlgorithm; - ttl: string; - }; - - type TRenewalTransactionResult = TInternalRenewalData | TExternalRenewalData; - const renewCertificate = async ({ certificateId, actor, @@ -1356,10 +1419,11 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, internal = false, - removeRootsFromChain, - certificateRequestId - }: TRenewCertificateDTO & { internal?: boolean }): Promise => { - const renewalResult: TRenewalTransactionResult = await certificateDAL.transaction(async (tx) => { + removeRootsFromChain + }: Omit & { + internal?: boolean; + }): Promise => { + const renewalResult = await certificateDAL.transaction(async (tx) => { const originalCert = await certificateDAL.findById(certificateId, tx); if (!originalCert) { throw new NotFoundError({ message: "Certificate not found" }); @@ -1576,7 +1640,7 @@ export const certificateV3ServiceFactory = ({ issuingCaCertificate = caResult.issuingCaCertificate; serialNumber = caResult.serialNumber; - const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + const foundCert = await certificateDAL.findById(caResult.certificateId, tx); if (!foundCert) { throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } @@ -1665,6 +1729,27 @@ export const certificateV3ServiceFactory = ({ await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx); + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: originalCert.projectId, + tx, + profileId: originalCert.profileId || undefined, + commonName: originalCert.commonName || undefined, + altNames: originalCert.altNames || undefined, + keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), + notBefore: new Date(newCert.notBefore), + notAfter: new Date(newCert.notAfter), + keyAlgorithm: originalKeyAlgorithm, + signatureAlgorithm: originalSignatureAlgorithm, + metadata: `Renewed from certificate ID: ${originalCert.id}` + }); + return { certificate, certificateChain, @@ -1672,10 +1757,13 @@ export const certificateV3ServiceFactory = ({ serialNumber, newCert, originalCert, - profile + profile, + certRequestResult }; }); + let certificateRequestId: string = renewalResult.certRequestResult?.id || ""; + // Handle external CA renewals separately if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) { const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult; @@ -1685,6 +1773,27 @@ export const certificateV3ServiceFactory = ({ ? originalCert.altNames.split(",").map((san: string) => san.trim()) : []; + const certificateRequest = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: originalCert.projectId, + profileId: profile?.id, + caId: ca.id, + commonName: originalCert.commonName || undefined, + altNames: originalCert.altNames || undefined, + keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), + keyAlgorithm: originalKeyAlgorithm, + signatureAlgorithm: originalSignatureAlgorithm, + metadata: `Renewed from certificate ID: ${originalCert.id}` + }); + + certificateRequestId = certificateRequest.id; + await certificateIssuanceQueue.queueCertificateIssuance({ certificateId: renewalOrderId, profileId: profile?.id || "", @@ -1698,7 +1807,7 @@ export const certificateV3ServiceFactory = ({ extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)), isRenewal: true, originalCertificateId: certificateId, - certificateRequestId + certificateRequestId: certificateRequest.id }); return { @@ -1707,6 +1816,7 @@ export const certificateV3ServiceFactory = ({ issuingCaCertificate: "", serialNumber: "", certificateId: renewalOrderId, + certificateRequestId: certificateRequest.id, projectId: originalCert.projectId, profileName: profile?.slug || "External CA Profile", commonName: originalCert.commonName || "" @@ -1734,6 +1844,7 @@ export const certificateV3ServiceFactory = ({ certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, + certificateRequestId, projectId: renewalResult.originalCert.projectId, profileName: renewalResult.profile?.slug || "Self-signed Certificate", commonName: renewalResult.originalCert.commonName || "" diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index d448cb770..6ea57c6a0 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; +import { ACMESANType } from "../certificate/certificate-types"; import { CertExtendedKeyUsageType, CertKeyUsageType, @@ -59,9 +59,9 @@ export type TOrderCertificateFromProfileDTO = { signatureAlgorithm?: string; keyAlgorithm?: string; template?: string; + csr?: string; }; removeRootsFromChain?: boolean; - certificateRequestId?: string; } & Omit; export type TCertificateFromProfileResponse = { @@ -71,34 +71,14 @@ export type TCertificateFromProfileResponse = { privateKey?: string; serialNumber: string; certificateId: string; + certificateRequestId: string; projectId: string; profileName: string; commonName: string; }; export type TCertificateOrderResponse = { - orderId: string; - status: CertificateOrderStatus; - subjectAlternativeNames: Array<{ - type: ACMESANType; - value: string; - status: CertificateOrderStatus; - }>; - authorizations: Array<{ - identifier: { - type: ACMESANType; - value: string; - }; - status: CertificateOrderStatus; - expires?: string; - challenges: Array<{ - type: string; - status: CertificateOrderStatus; - url: string; - token: string; - }>; - }>; - finalize: string; + certificateRequestId: string; certificate?: string; projectId: string; profileName: string; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 2d32a007b..4d34822b3 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -83,32 +83,40 @@ export type TDownloadPkcs12DTO = { export type TUnifiedCertificateIssuanceDTO = { projectSlug: string; + profileId: string; projectId: string; - profileId?: string; - caId?: string; csr?: string; - commonName?: string; - altNames?: string; - keyUsages?: string[]; - extendedKeyUsages?: string[]; - notBefore?: Date; - notAfter?: Date; - keyAlgorithm?: string; - signatureAlgorithm?: string; - ttl?: string; - friendlyName?: string; - pkiCollectionId?: string; - issuerType?: string; + attributes?: { + commonName?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + altNames?: Array<{ + type: string; + value: string; + }>; + signatureAlgorithm: string; + keyAlgorithm: string; + subjectAlternativeNames?: Array<{ + type: string; + value: string; + }>; + ttl: string; + notBefore?: string; + notAfter?: string; + }; + removeRootsFromChain?: boolean; }; export type TUnifiedCertificateResponse = { - certificate: string; - issuingCaCertificate: string; - certificateChain: string; - privateKey?: string; - serialNumber: string; - certificateId: string; - projectId: string; + certificate: { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + }; + certificateRequestId: string; }; export type TCertificateRequestResponse = { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index c544e2a66..b5531dacf 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -107,6 +107,7 @@ type TCertificateDetails = { certificate?: string; certificateChain?: string; privateKey?: string; + issuingCaCertificate?: string; }; export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { @@ -280,41 +281,42 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } profileId: formProfileId, projectSlug: currentProject.slug, projectId: currentProject.id, - ttl, - keyUsages: filterUsages(keyUsages) as CertKeyUsage[], - extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] + attributes: { + ttl, + signatureAlgorithm: signatureAlgorithm || "", + keyAlgorithm: keyAlgorithm || "", + keyUsages: filterUsages(keyUsages) as CertKeyUsage[], + extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] + } }; if (constraints.shouldShowSubjectSection && commonName) { - request.commonName = commonName; - } - - if (signatureAlgorithm) { - request.signatureAlgorithm = signatureAlgorithm; - } - - if (keyAlgorithm) { - request.keyAlgorithm = keyAlgorithm; + request.attributes.commonName = commonName; } if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { const formattedSans = formatSubjectAltNames(subjectAltNames); if (formattedSans && formattedSans.length > 0) { - request.altNames = formattedSans; + request.attributes.altNames = formattedSans; + request.attributes.subjectAlternativeNames = formattedSans; } } const response = await issueCertificate(request); // Handle certificate issuance response - if ("certificate" in response) { - // Immediate certificate issuance - setCertificateDetails({ - serialNumber: response.serialNumber, - certificate: response.certificate, - certificateChain: response.certificateChain, - privateKey: response.privateKey - }); + + if ("certificate" in response && response.certificate) { + const certData = response.certificate; + const certificateDetailsToSet = { + serialNumber: certData.serialNumber || "", + certificate: certData.certificate || "", + certificateChain: certData.certificateChain || "", + privateKey: certData.privateKey || "", + issuingCaCertificate: certData.issuingCaCertificate || "" + }; + + setCertificateDetails(certificateDetailsToSet); createNotification({ text: "Successfully created certificate", From 36ccc3769a7f9d2484d071d7098038c74e451ff5 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 2 Dec 2025 12:46:12 -0300 Subject: [PATCH 5/8] Address PR comments --- .../routes/v1/certificate-authority-router.ts | 19 ++-- .../server/routes/v1/certificate-router.ts | 9 +- .../v1/deprecated-certificate-router.ts | 17 ++-- .../v3/deprecated-certificates-router.ts | 4 + .../internal-certificate-authority-service.ts | 28 +++--- .../internal-certificate-authority-types.ts | 2 + .../certificate-request-service.test.ts | 8 +- .../certificate-request-service.ts | 53 ++++++------ .../certificate-request-types.ts | 2 + .../certificate-v3/certificate-v3-service.ts | 86 +++++++++++-------- frontend/src/hooks/api/ca/queries.tsx | 6 +- .../src/hooks/api/certificates/mutations.tsx | 5 +- .../components/CertificateIssuanceModal.tsx | 3 +- .../components/CertificateRenewalModal.tsx | 2 +- .../components/CertificateRequestTracker.tsx | 4 +- .../CreateProfileModal.tsx | 5 +- 16 files changed, 139 insertions(+), 114 deletions(-) diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 96e0db219..2917270ef 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -665,16 +665,15 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const response = await server.services.internalCertificateAuthority.issueCertFromCa({ - caId: req.params.caId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.body - }); - - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = response; + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.internalCertificateAuthority.issueCertFromCa({ + caId: req.params.caId, + 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, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 3823f791d..492902a6f 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -170,8 +170,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { serialNumber: z.string().trim(), certificateId: z.string() }) - .nullable() - .optional(), + .nullable(), certificateRequestId: z.string() }) } @@ -373,11 +372,9 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId: (req.query as { projectId: string }).projectId, event: { - type: EventType.GET_CERT, + type: EventType.GET_CERTIFICATE_REQUEST, metadata: { - certId: req.params.requestId, - cn: "", - serialNumber: data.serialNumber || "" + certificateRequestId: req.params.requestId } } }); diff --git a/backend/src/server/routes/v1/deprecated-certificate-router.ts b/backend/src/server/routes/v1/deprecated-certificate-router.ts index 73072ea31..955407e4c 100644 --- a/backend/src/server/routes/v1/deprecated-certificate-router.ts +++ b/backend/src/server/routes/v1/deprecated-certificate-router.ts @@ -242,15 +242,14 @@ export const registerDeprecatedCertRouter = async (server: FastifyZodProvider) = } }, handler: async (req) => { - const response = await server.services.internalCertificateAuthority.issueCertFromCa({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - ...req.body - }); - - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = response; + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.internalCertificateAuthority.issueCertFromCa({ + 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, diff --git a/backend/src/server/routes/v3/deprecated-certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts index beaffd5f6..fc3a05ecb 100644 --- a/backend/src/server/routes/v3/deprecated-certificates-router.ts +++ b/backend/src/server/routes/v3/deprecated-certificates-router.ts @@ -17,6 +17,7 @@ import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { booleanSchema } from "../sanitizedSchemas"; @@ -138,6 +139,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }); const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + status: CertificateRequestStatus.ISSUED, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -244,6 +246,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ actor: req.permission.type, + status: CertificateRequestStatus.ISSUED, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, @@ -365,6 +368,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }); const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + status: CertificateRequestStatus.PENDING, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, 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 f3c56e322..2b7c155db 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 @@ -1575,15 +1575,16 @@ export const internalCertificateAuthorityServiceFactory = ({ keyUsages, extendedKeyUsages, signatureAlgorithm, - keyAlgorithm + keyAlgorithm, + tx } = dto; let collectionId = pkiCollectionId; if (caId) { - ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); } else if (certificateTemplateId) { - certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId, tx); if (!certificateTemplate) { throw new NotFoundError({ message: `Certificate template with ID '${certificateTemplateId}' not found` @@ -1591,7 +1592,7 @@ export const internalCertificateAuthorityServiceFactory = ({ } collectionId = certificateTemplate.pkiCollectionId as string; - ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId, tx); } if (!ca) { @@ -1640,7 +1641,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // check PKI collection if (pkiCollectionId) { - const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId, tx); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); } @@ -1909,7 +1910,7 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChainPem) }); - const cert = await certificateDAL.transaction(async (tx) => { + const createSignedCert = async (transaction: Knex) => { const newCert = await certificateDAL.create( { caId: (ca as TCertificateAuthorities).id, @@ -1928,7 +1929,7 @@ export const internalCertificateAuthorityServiceFactory = ({ keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm, signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, - tx + transaction ); await certificateBodyDAL.create( @@ -1937,7 +1938,7 @@ export const internalCertificateAuthorityServiceFactory = ({ encryptedCertificate, encryptedCertificateChain }, - tx + transaction ); if (collectionId) { @@ -1946,12 +1947,19 @@ export const internalCertificateAuthorityServiceFactory = ({ pkiCollectionId: collectionId, certId: newCert.id }, - tx + transaction ); } return newCert; - }); + }; + + let cert; + if (tx) { + cert = await createSignedCert(tx); + } else { + cert = await certificateDAL.transaction(createSignedCert); + } return { certificate: leafCert, diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index 60b9c65c5..c13f85aa5 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -160,6 +160,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } | ({ isInternal: false; @@ -179,6 +180,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } & Omit); export type TGetCaCertificateTemplatesDTO = { diff --git a/backend/src/services/certificate-request/certificate-request-service.test.ts b/backend/src/services/certificate-request/certificate-request-service.test.ts index 2df3a2354..5e6b870bf 100644 --- a/backend/src/services/certificate-request/certificate-request-service.test.ts +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -71,7 +71,8 @@ describe("CertificateRequestService", () => { actorOrgId: "550e8400-e29b-41d4-a716-446655440002", projectId: "550e8400-e29b-41d4-a716-446655440003", profileId: "550e8400-e29b-41d4-a716-446655440004", - commonName: "test.example.com" + commonName: "test.example.com", + status: CertificateRequestStatus.PENDING }; it("should create certificate request successfully", async () => { @@ -105,12 +106,13 @@ describe("CertificateRequestService", () => { actionProjectType: ActionProjectType.CertificateManager }); expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith( - expect.objectContaining({ + { status: CertificateRequestStatus.PENDING, projectId: "550e8400-e29b-41d4-a716-446655440003", profileId: "550e8400-e29b-41d4-a716-446655440004", commonName: "test.example.com" - }) + }, + undefined ); expect(result).toEqual(mockCreatedRequest); }); diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts index 67cd6d77c..15450ee6e 100644 --- a/backend/src/services/certificate-request/certificate-request-service.ts +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -12,6 +12,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { ActorType } from "../auth/auth-type"; import { TCertificateRequestDALFactory } from "./certificate-request-dal"; import { CertificateRequestStatus, @@ -44,7 +45,8 @@ const certificateRequestDataSchema = z notAfter: z.date().optional(), keyAlgorithm: z.string().max(100).optional(), signatureAlgorithm: z.string().max(100).optional(), - metadata: z.string().max(2000).optional() + metadata: z.string().max(2000).optional(), + certificateId: z.string().optional() }) .refine( (data) => { @@ -94,39 +96,36 @@ export const certificateRequestServiceFactory = ({ actorOrgId, projectId, tx, + status, ...requestData }: TCreateCertificateRequestDTO & { tx?: Knex }) => { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (actor !== ActorType.ACME_ACCOUNT) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } // Validate input data before creating the request const validatedData = validateCertificateRequestData(requestData); - const certificateRequest = tx - ? await certificateRequestDAL.create( - { - status: CertificateRequestStatus.PENDING, - projectId, - ...validatedData - }, - tx - ) - : await certificateRequestDAL.create({ - status: CertificateRequestStatus.PENDING, - projectId, - ...validatedData - }); + const certificateRequest = await certificateRequestDAL.create( + { + status, + projectId, + ...validatedData + }, + tx + ); return certificateRequest; }; diff --git a/backend/src/services/certificate-request/certificate-request-types.ts b/backend/src/services/certificate-request/certificate-request-types.ts index 98fe05fe5..c8a00de7e 100644 --- a/backend/src/services/certificate-request/certificate-request-types.ts +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -19,6 +19,8 @@ export type TCreateCertificateRequestDTO = TProjectPermission & { keyAlgorithm?: string; signatureAlgorithm?: string; metadata?: string; + status: CertificateRequestStatus; + certificateId?: string; }; export type TGetCertificateRequestDTO = TProjectPermission & { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 21e315a19..1c888a6e5 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -19,10 +19,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { - CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyType, - CertKeyUsage, CertSignatureAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; @@ -58,15 +56,14 @@ import { bufferToString, buildCertificateSubjectFromTemplate, buildSubjectAlternativeNamesFromTemplate, - convertExtendedKeyUsageArrayFromLegacy, convertExtendedKeyUsageArrayToLegacy, - convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, normalizeDateForApi, removeRootCaFromChain } from "../certificate-common/certificate-utils"; import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "../certificate-request/certificate-request-types"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; @@ -307,14 +304,15 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s return bufferToString(certData as unknown as Buffer); }; -const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { +const parseKeyUsages = (keyUsages: unknown): CertKeyUsageType[] => { if (!keyUsages) return []; - const validKeyUsages = Object.values(CertKeyUsage); + const validKeyUsages = Object.values(CertKeyUsageType); if (Array.isArray(keyUsages)) { return keyUsages.filter( - (usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage) + (usage): usage is CertKeyUsageType => + typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsageType) ); } @@ -322,21 +320,21 @@ const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { return keyUsages .split(",") .map((usage) => usage.trim()) - .filter((usage): usage is CertKeyUsage => validKeyUsages.includes(usage as CertKeyUsage)); + .filter((usage): usage is CertKeyUsageType => validKeyUsages.includes(usage as CertKeyUsageType)); } return []; }; -const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { +const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsageType[] => { if (!extendedKeyUsages) return []; - const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage); + const validExtendedKeyUsages = Object.values(CertExtendedKeyUsageType); if (Array.isArray(extendedKeyUsages)) { return extendedKeyUsages.filter( - (usage): usage is CertExtendedKeyUsage => - typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage) + (usage): usage is CertExtendedKeyUsageType => + typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType) ); } @@ -344,7 +342,9 @@ const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsag return extendedKeyUsages .split(",") .map((usage) => usage.trim()) - .filter((usage): usage is CertExtendedKeyUsage => validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)); + .filter((usage): usage is CertExtendedKeyUsageType => + validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType) + ); } return []; @@ -972,7 +972,9 @@ export const certificateV3ServiceFactory = ({ notBefore: certificateRequest.notBefore, notAfter: certificateRequest.notAfter, keyAlgorithm: effectiveKeyAlgorithm, - signatureAlgorithm: effectiveSignatureAlgorithm + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: selfSignedResult.certificateData.id }); return { ...selfSignedResult, certificateRequestId: certRequestResult.id }; @@ -1048,7 +1050,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - isFromProfile: true + isFromProfile: true, + tx }); const certificateRecord = await certificateDAL.findById(certResult.certificateId); @@ -1066,7 +1069,7 @@ export const certificateV3ServiceFactory = ({ if (finalRenewBeforeDays !== undefined) { updateData.renewBeforeDays = finalRenewBeforeDays; } - await certificateDAL.updateById(certificateRecord.id, updateData); + await certificateDAL.updateById(certificateRecord.id, updateData, tx); const certRequestResult = await certificateRequestService.createCertificateRequest({ actor, @@ -1075,6 +1078,7 @@ export const certificateV3ServiceFactory = ({ actorOrgId, projectId: profile.projectId, tx, + caId: ca.id, profileId: profile.id, commonName: certificateRequest.commonName, altNames: certificateRequest.altNames?.map((san) => san.value).join(","), @@ -1083,7 +1087,9 @@ export const certificateV3ServiceFactory = ({ notBefore: certificateRequest.notBefore, notAfter: certificateRequest.notAfter, keyAlgorithm: effectiveKeyAlgorithm, - signatureAlgorithm: effectiveSignatureAlgorithm + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: certResult.certificateId }); return { ...certResult, cert: certificateRecord, certificateRequestId: certRequestResult.id }; @@ -1197,7 +1203,8 @@ export const certificateV3ServiceFactory = ({ notAfter: normalizeDateForApi(notAfter), signatureAlgorithm: effectiveSignatureAlgorithm, keyAlgorithm: effectiveKeyAlgorithm, - isFromProfile: true + isFromProfile: true, + tx }); const signedCertRecord = await certificateDAL.findById(certResult.certificateId); @@ -1215,7 +1222,7 @@ export const certificateV3ServiceFactory = ({ if (finalRenewBeforeDays !== undefined) { updateData.renewBeforeDays = finalRenewBeforeDays; } - await certificateDAL.updateById(signedCertRecord.id, updateData); + await certificateDAL.updateById(signedCertRecord.id, updateData, tx); const certRequestResult = await certificateRequestService.createCertificateRequest({ actor, @@ -1224,6 +1231,7 @@ export const certificateV3ServiceFactory = ({ actorOrgId, projectId: profile.projectId, tx, + caId: ca.id, profileId: profile.id, csr, commonName: mappedCertificateRequest.commonName, @@ -1233,7 +1241,9 @@ export const certificateV3ServiceFactory = ({ notBefore, notAfter, keyAlgorithm: effectiveKeyAlgorithm, - signatureAlgorithm: effectiveSignatureAlgorithm + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: certResult.certificateId }); return { ...certResult, cert: signedCertRecord, certificateRequestId: certRequestResult.id }; @@ -1370,6 +1380,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, projectId: profile.projectId, + caId: ca.id, profileId: profile.id, commonName: certificateOrder.commonName || "", keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [], @@ -1380,7 +1391,8 @@ export const certificateV3ServiceFactory = ({ signatureAlgorithm: certificateOrder.signatureAlgorithm || "", altNames: certificateOrder.altNames?.map((san) => san.value).join(",") || "", notBefore: certificateOrder.notBefore, - notAfter: certificateOrder.notAfter + notAfter: certificateOrder.notAfter, + status: CertificateRequestStatus.PENDING }); await certificateIssuanceQueue.queueCertificateIssuance({ @@ -1554,10 +1566,8 @@ export const certificateV3ServiceFactory = ({ const certificateRequest = { commonName: originalCert.commonName || undefined, - keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), - extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( - parseExtendedKeyUsages(originalCert.extendedKeyUsages) - ), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), subjectAlternativeNames: originalCert.altNames ? originalCert.altNames.split(",").map((san) => detectSanType(san.trim())) : [], @@ -1622,8 +1632,10 @@ export const certificateV3ServiceFactory = ({ ttl, notBefore: normalizeDateForApi(notBefore), notAfter: normalizeDateForApi(notAfter), - keyUsages: parseKeyUsages(originalCert.keyUsages), - extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + keyUsages: convertKeyUsageArrayToLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), signatureAlgorithm: originalSignatureAlgorithm, keyAlgorithm: originalKeyAlgorithm, isFromProfile: true, @@ -1736,18 +1748,19 @@ export const certificateV3ServiceFactory = ({ actorOrgId, projectId: originalCert.projectId, tx, + caId: ca?.id || originalCert.caId || undefined, profileId: originalCert.profileId || undefined, commonName: originalCert.commonName || undefined, altNames: originalCert.altNames || undefined, - keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), - extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( - parseExtendedKeyUsages(originalCert.extendedKeyUsages) - ), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), notBefore: new Date(newCert.notBefore), notAfter: new Date(newCert.notAfter), keyAlgorithm: originalKeyAlgorithm, signatureAlgorithm: originalSignatureAlgorithm, - metadata: `Renewed from certificate ID: ${originalCert.id}` + metadata: `Renewed from certificate ID: ${originalCert.id}`, + status: CertificateRequestStatus.ISSUED, + certificateId: newCert.id }); return { @@ -1783,13 +1796,12 @@ export const certificateV3ServiceFactory = ({ caId: ca.id, commonName: originalCert.commonName || undefined, altNames: originalCert.altNames || undefined, - keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), - extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( - parseExtendedKeyUsages(originalCert.extendedKeyUsages) - ), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), keyAlgorithm: originalKeyAlgorithm, signatureAlgorithm: originalSignatureAlgorithm, - metadata: `Renewed from certificate ID: ${originalCert.id}` + metadata: `Renewed from certificate ID: ${originalCert.id}`, + status: CertificateRequestStatus.PENDING }); certificateRequestId = certificateRequest.id; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 09e4c0f3b..d2e1836bb 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -189,10 +189,12 @@ export const useGetCaCertTemplates = (caId: string) => { export const useGetAzureAdcsTemplates = ({ caId, - projectId + projectId, + isAzureAdcsCa }: { caId: string; projectId: string; + isAzureAdcsCa: boolean; }) => { return useQuery({ queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), @@ -202,6 +204,6 @@ export const useGetAzureAdcsTemplates = ({ }>(`/api/v1/cert-manager/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); return data; }, - enabled: Boolean(caId && projectId) + enabled: Boolean(caId && projectId && isAzureAdcsCa) }); }; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index e3e6d6c36..03605ac90 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -195,10 +195,7 @@ export const useUnifiedCertificateIssuance = () => { const { projectSlug, ...requestData } = body; const { data } = await apiRequest.post( "/api/v1/cert-manager/certificates", - requestData, - { - params: { projectSlug } - } + requestData ); return data; }, diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index b5531dacf..fff941681 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -298,7 +298,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const formattedSans = formatSubjectAltNames(subjectAltNames); if (formattedSans && formattedSans.length > 0) { request.attributes.altNames = formattedSans; - request.attributes.subjectAlternativeNames = formattedSans; } } @@ -325,7 +324,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } else { // Certificate request - async processing createNotification({ - text: `Certificate request submitted successfully. This may take a few minutes to process. Request ID: ${response.certificateRequestId}`, + text: `Certificate request submitted successfully. This may take a few minutes to process. Certificate Request ID: ${response.certificateRequestId}`, type: "success" }); handlePopUpToggle("issueCertificate", false); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx index 48482c064..3459beed5 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx @@ -25,7 +25,7 @@ export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => }); const notificationText = result.certificateRequestId - ? `Certificate renewal initiated successfully. Request ID: ${result.certificateRequestId}` + ? `Certificate renewal initiated successfully. Certificate Request ID: ${result.certificateRequestId}` : "Certificate renewed successfully"; createNotification({ diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx index 69f9ef446..2d6453963 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx @@ -71,7 +71,9 @@ export const CertificateRequestTracker = ({ requestId, onCertificateIssued }: Pr
{getStatusIcon()} - Request ID: {requestId} + + Certificate Request ID: {requestId} +
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index e7530027b..138e60de3 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -447,7 +447,8 @@ export const CreateProfileModal = ({ // Fetch Azure ADCS templates if needed const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({ caId: watchedCertificateAuthorityId || "", - projectId: currentProject?.id || "" + projectId: currentProject?.id || "", + isAzureAdcsCa }); useEffect(() => { @@ -732,7 +733,7 @@ export const CreateProfileModal = ({ name="externalConfigs.template" render={({ field: { onChange, value }, fieldState: { error } }) => ( Date: Tue, 2 Dec 2025 12:55:26 -0300 Subject: [PATCH 6/8] Fix type error --- .../PkiSubscribersPage/components/PkiSubscriberModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx index 27c91851c..054cbcc45 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -213,7 +213,8 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { // Fetch Azure ADCS templates when Azure CA is selected const { data: azureTemplates } = useGetAzureAdcsTemplates({ caId: selectedCa?.type === CaType.AZURE_AD_CS ? selectedCaId : "", - projectId + projectId, + isAzureAdcsCa: true }); // Initialize form with ALL subscriber data including template From 6772eb54783473e11e836f7a0288348bf5247719 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 2 Dec 2025 14:49:24 -0300 Subject: [PATCH 7/8] Fix missing tx in sign cert flow --- backend/src/services/certificate-v3/certificate-v3-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 1c888a6e5..fd3b85e76 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1207,7 +1207,7 @@ export const certificateV3ServiceFactory = ({ tx }); - const signedCertRecord = await certificateDAL.findById(certResult.certificateId); + const signedCertRecord = await certificateDAL.findById(certResult.certificateId, tx); if (!signedCertRecord) { throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } From ec115bc4fba45613ea99d7d739c58d2dd7b56483 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 2 Dec 2025 16:26:36 -0300 Subject: [PATCH 8/8] Remove ACMESANType usage on redundant issuance param --- .../server/routes/v1/certificate-router.ts | 35 +----- .../v3/deprecated-certificates-router.ts | 4 +- .../certificate-issuance-queue.ts | 2 +- .../certificate-v3-service.test.ts | 7 +- .../certificate-v3/certificate-v3-service.ts | 22 +--- .../certificate-v3/certificate-v3-types.ts | 3 +- frontend/src/hooks/api/certificates/index.tsx | 2 +- .../src/hooks/api/certificates/queries.tsx | 22 +--- .../components/CertificateRequestTracker.tsx | 110 ------------------ 9 files changed, 16 insertions(+), 191 deletions(-) delete mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 492902a6f..f14ff437f 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -11,12 +11,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { addNoCacheHeaders } from "@app/server/lib/caching"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - ACMESANType, - CertKeyAlgorithm, - CertSignatureAlgorithm, - CrlReason -} from "@app/services/certificate/certificate-types"; +import { CertKeyAlgorithm, CertSignatureAlgorithm, CrlReason } from "@app/services/certificate/certificate-types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { @@ -129,18 +124,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { .optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) - .optional(), ttl: z .string() .trim() @@ -199,19 +182,9 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { useOrderFlow = caType !== CaType.INTERNAL; } - if (attributes?.subjectAlternativeNames?.length || useOrderFlow) { - let acmeAltNames: Array<{ type: ACMESANType; value: string }> | undefined = attributes?.subjectAlternativeNames; - if (useOrderFlow && !attributes?.subjectAlternativeNames && attributes?.altNames?.length) { - acmeAltNames = attributes.altNames.map((alt: { type: CertSubjectAlternativeNameType; value: string }) => ({ - type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME - ? ACMESANType.DNS - : ACMESANType.IP) as ACMESANType, - value: alt.value - })); - } - + if (useOrderFlow) { const certificateOrderObject = { - altNames: acmeAltNames || [], + altNames: attributes?.altNames || [], validity: { ttl: attributes?.ttl || "" }, commonName: attributes?.commonName, keyUsages: attributes?.keyUsages, @@ -579,7 +552,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), subjectAlternativeNames: z.array( z.object({ - type: z.nativeEnum(ACMESANType), + type: z.nativeEnum(CertSubjectAlternativeNameType), value: z .string() .trim() diff --git a/backend/src/server/routes/v3/deprecated-certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts index fc3a05ecb..ab8ae176c 100644 --- a/backend/src/server/routes/v3/deprecated-certificates-router.ts +++ b/backend/src/server/routes/v3/deprecated-certificates-router.ts @@ -7,7 +7,7 @@ import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { ACMESANType, CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; +import { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -305,7 +305,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => profileId: z.string().uuid(), subjectAlternativeNames: z.array( z.object({ - type: z.nativeEnum(ACMESANType), + type: z.nativeEnum(CertSubjectAlternativeNameType), value: z .string() .trim() diff --git a/backend/src/services/certificate-authority/certificate-issuance-queue.ts b/backend/src/services/certificate-authority/certificate-issuance-queue.ts index 13fd68804..f590f2850 100644 --- a/backend/src/services/certificate-authority/certificate-issuance-queue.ts +++ b/backend/src/services/certificate-authority/certificate-issuance-queue.ts @@ -208,7 +208,7 @@ export const certificateIssuanceQueueFactory = ({ const [, generatedCsr] = await acme.crypto.createCsr( { - altNames: altNames || [], + altNames: altNames ? [...altNames] : [], commonName: commonName || "" }, skLeaf diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index b3db6f736..9d8d1aebb 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -11,7 +11,7 @@ import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-ac import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; -import { ACMESANType, CertStatus } from "@app/services/certificate/certificate-types"; +import { CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; @@ -19,7 +19,8 @@ import { CertExtendedKeyUsageType, CertIncludeType, CertKeyUsageType, - CertSubjectAttributeType + CertSubjectAttributeType, + CertSubjectAlternativeNameType } from "@app/services/certificate-common/certificate-constants"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; @@ -853,7 +854,7 @@ describe("CertificateV3Service", () => { describe("orderCertificateFromProfile", () => { const mockCertificateOrder = { - altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + altNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }], validity: { ttl: "30d" }, commonName: "example.com", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index fd3b85e76..20e5d3e1c 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1054,7 +1054,7 @@ export const certificateV3ServiceFactory = ({ tx }); - const certificateRecord = await certificateDAL.findById(certResult.certificateId); + const certificateRecord = await certificateDAL.findById(certResult.certificateId, tx); if (!certificateRecord) { throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); } @@ -1307,25 +1307,7 @@ export const certificateV3ServiceFactory = ({ commonName: certificateOrder.commonName, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames?.map((san) => { - let certType: CertSubjectAlternativeNameType; - switch (san.type) { - case "dns": - certType = CertSubjectAlternativeNameType.DNS_NAME; - break; - case "ip": - certType = CertSubjectAlternativeNameType.IP_ADDRESS; - break; - default: - throw new BadRequestError({ - message: `Unsupported Subject Alternative Name type: ${san.type as string}` - }); - } - return { - type: certType, - value: san.value - }; - }), + subjectAlternativeNames: certificateOrder.altNames, validity: certificateOrder.validity, notBefore: certificateOrder.notBefore, notAfter: certificateOrder.notAfter, diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 6ea57c6a0..50d710406 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -1,6 +1,5 @@ import { TProjectPermission } from "@app/lib/types"; -import { ACMESANType } from "../certificate/certificate-types"; import { CertExtendedKeyUsageType, CertKeyUsageType, @@ -45,7 +44,7 @@ export type TOrderCertificateFromProfileDTO = { profileId: string; certificateOrder: { altNames: Array<{ - type: ACMESANType; + type: CertSubjectAlternativeNameType; value: string; }>; validity: { diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index 19882d8c3..bc7f80d2c 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -7,4 +7,4 @@ export { useRevokeCert, useUpdateRenewalConfig } from "./mutations"; -export { useGetCert, useGetCertBody, useGetCertificateRequest } from "./queries"; +export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 9b6205e2d..13245894b 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificate, TCertificateRequestDetails } from "./types"; +import { TCertificate } from "./types"; export const certKeys = { getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], @@ -59,23 +59,3 @@ export const useGetCertBundle = (serialNumber: string) => { enabled: Boolean(serialNumber) }); }; - -export const useGetCertificateRequest = (requestId: string, projectSlug: string) => { - return useQuery({ - queryKey: certKeys.getCertificateRequest(requestId, projectSlug), - queryFn: async () => { - const { data } = await apiRequest.get( - `/api/v3/pki/certificates/requests/${requestId}`, - { - params: { projectSlug } - } - ); - return data; - }, - enabled: Boolean(requestId) && Boolean(projectSlug), - refetchInterval: (query) => { - // Only refetch if status is pending - return query.state.data?.status === "pending" ? 5000 : false; - } - }); -}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx deleted file mode 100644 index 2d6453963..000000000 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRequestTracker.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect } from "react"; -import { faCheck, faExclamationTriangle, faSpinner } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { useProject } from "@app/context"; -import { useGetCertificateRequest } from "@app/hooks/api/certificates"; - -type CertificateInfo = { - id: string; - serialNumber: string; - commonName: string; - notAfter: string; - [key: string]: unknown; -}; - -type Props = { - requestId: string; - onCertificateIssued?: (certificate: CertificateInfo) => void; -}; - -export const CertificateRequestTracker = ({ requestId, onCertificateIssued }: Props) => { - const { currentProject } = useProject(); - - const { data: requestData, isLoading } = useGetCertificateRequest( - requestId, - currentProject?.slug || "" - ); - - useEffect(() => { - if (requestData?.status === "issued" && requestData.certificate && onCertificateIssued) { - onCertificateIssued(requestData.certificate); - } - }, [requestData, onCertificateIssued]); - - if (isLoading) { - return ( -
- - Loading request status... -
- ); - } - - const getStatusIcon = () => { - switch (requestData?.status) { - case "pending": - return ; - case "issued": - return ; - case "failed": - return ; - default: - return null; - } - }; - - const getStatusMessage = () => { - switch (requestData?.status) { - case "pending": - return "Certificate request is being processed..."; - case "issued": - return "Certificate has been issued successfully!"; - case "failed": - return `Certificate request failed: ${requestData.errorMessage || "Unknown error"}`; - default: - return "Unknown status"; - } - }; - - return ( -
-
- {getStatusIcon()} - - Certificate Request ID: {requestId} - -
- -
- Status: {requestData?.status || "Unknown"} -
- -
{getStatusMessage()}
- - {requestData?.status === "issued" && requestData.certificate && ( -
-
- Certificate Details: -
- Serial Number: {requestData.certificate.serialNumber} -
- Common Name: {requestData.certificate.commonName} -
- Valid Until: {new Date(requestData.certificate.notAfter).toLocaleDateString()} -
-
- )} - - {requestData?.status === "failed" && requestData.errorMessage && ( -
-
- Error Details: -
- {requestData.errorMessage} -
-
- )} -
- ); -};