diff --git a/.infisicalignore b/.infisicalignore index 7a07a95045..d0f302ce0a 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 6ef775f902..02394de4df 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 603df5f6ca..4bdd3849d1 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 0000000000..32944c37d7 --- /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 0000000000..86ad4f5b4f --- /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 0000000000..e01e08bbd1 --- /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 78dcb1980c..7db6e847d5 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 444a6bd97d..040d6e2785 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 c0dd891f0b..0cf9cf160f 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 591cac688d..71b7a7b70e 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 8cf8555f99..a7ed3a1709 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 00771168cf..4d5a498a08 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 b23dd1fee2..371ee1d778 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 f13f77c346..59a729505e 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 ff95083c6c..2e1eafbbe9 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 26f59a402e..8b2e23bd43 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 2c2dfe4843..004e3d4a5e 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 ed27f75711..132e8b148c 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 0000000000..846d0c4bc6 --- /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 dc74e2ea41..7a6494d346 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 122dde8cdf..17ddcaa30d 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 0ce3ca9b63..a771b7b0a4 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 85260b25db..41c1b47464 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 0000000000..93d13f0ba8 --- /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 0000000000..fec4ce9aeb --- /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 0000000000..b277838229 --- /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 0000000000..98fe05fe58 --- /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 0000000000..58a3a5d201 --- /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 93291b0517..bab58385fb 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 0fadbc8dea..d8fb33cd02 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 ab638c5ed8..d448cb7704 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 176a5dd9e6..a4f6236595 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 bc7f80d2cb..19882d8c33 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 f65d6adc6a..871d06babb 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 50f2836edf..9b6205e2d4 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 ee543aac71..27cb060b31 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 5f6462d2e9..c544e2a669 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 044bc9cfc3..edb16f519a 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 c2ec7b6884..1373c9fed9 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 && (