diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e3e8733f0..f4951d8f8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -61,7 +61,11 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; 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 { 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"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TConvertorServiceFactory } from "@app/services/convertor/convertor-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; @@ -262,7 +266,10 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateV3: TCertificateV3ServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + certificateTemplateV2: TCertificateTemplateV2ServiceFactory; + certificateProfile: TCertificateProfileServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; @@ -270,6 +277,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; pkiSync: TPkiSyncServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 619f7f92d..bbc27ebc1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -266,12 +266,24 @@ import { TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate, + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate, + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update, TPkiCollectionItems, TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate, TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, @@ -674,6 +686,26 @@ declare module "knex/types/tables" { TCertificateTemplatesInsert, TCertificateTemplatesUpdate >; + [TableName.PkiCertificateTemplateV2]: KnexOriginal.CompositeTableType< + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update + >; + [TableName.PkiCertificateProfile]: KnexOriginal.CompositeTableType< + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate + >; + [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate + >; + [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts new file mode 100644 index 000000000..713953f14 --- /dev/null +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -0,0 +1,117 @@ +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.PkiCertificateTemplateV2))) { + await knex.schema.createTable(TableName.PkiCertificateTemplateV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project); + + t.string("name").notNullable(); + t.string("description"); + + t.jsonb("subject"); + t.jsonb("sans"); + t.jsonb("keyUsages"); + t.jsonb("extendedKeyUsages"); + t.jsonb("algorithms"); + t.jsonb("validity"); + + t.timestamps(true, true, true); + + t.unique(["name", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); + } + + if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("disableBootstrapCaValidation").defaultTo(false); + t.text("hashedPassphrase").notNullable(); + t.binary("encryptedCaChain"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("autoRenew").defaultTo(false); + t.integer("autoRenewDays"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiCertificateProfile))) { + await knex.schema.createTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); + + t.uuid("certificateTemplateId").notNullable(); + t.foreign("certificateTemplateId").references("id").inTable(TableName.PkiCertificateTemplateV2); + + t.string("slug").notNullable(); + t.string("description"); + t.string("enrollmentType").notNullable().checkIn(["api", "est"]); + + t.uuid("estConfigId"); + t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL"); + + t.uuid("apiConfigId"); + t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL"); + + t.timestamps(true, true, true); + + t.unique(["slug", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + } + + if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("profileId"); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.index("profileId"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign(["profileId"]); + t.dropIndex("profileId"); + t.dropColumn("profileId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.PkiCertificateProfile); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + + await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiCertificateTemplateV2); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 6bedf01ad..63122f662 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({ keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), - pkiSubscriberId: z.string().uuid().nullable().optional() + pkiSubscriberId: z.string().uuid().nullable().optional(), + profileId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 6c718d097..4f0f221ff 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -92,8 +92,12 @@ export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; export * from "./pki-alerts"; +export * from "./pki-api-enrollment-configs"; +export * from "./pki-certificate-profiles"; +export * from "./pki-certificate-templates-v2"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-est-enrollment-configs"; export * from "./pki-subscribers"; export * from "./pki-syncs"; export * from "./project-bots"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f7291a70f..28e9471ad 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -23,6 +23,10 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiCertificateTemplateV2 = "pki_certificate_templates_v2", + PkiCertificateProfile = "pki_certificate_profiles", + PkiEstEnrollmentConfig = "pki_est_enrollment_configs", + PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts new file mode 100644 index 000000000..710b0dee4 --- /dev/null +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -0,0 +1,22 @@ +// 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 PkiApiEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + autoRenew: z.boolean().default(false).nullable().optional(), + autoRenewDays: z.number().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiApiEnrollmentConfigs = z.infer; +export type TPkiApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiApiEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts new file mode 100644 index 000000000..368770c3e --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -0,0 +1,28 @@ +// 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 PkiCertificateProfilesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z.string(), + description: z.string().nullable().optional(), + enrollmentType: z.string(), + estConfigId: z.string().uuid().nullable().optional(), + apiConfigId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateProfiles = z.infer; +export type TPkiCertificateProfilesInsert = Omit, TImmutableDBKeys>; +export type TPkiCertificateProfilesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-templates-v2.ts b/backend/src/db/schemas/pki-certificate-templates-v2.ts new file mode 100644 index 000000000..de4603887 --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-templates-v2.ts @@ -0,0 +1,29 @@ +// 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 PkiCertificateTemplatesV2Schema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + subject: z.unknown().nullable().optional(), + sans: z.unknown().nullable().optional(), + keyUsages: z.unknown().nullable().optional(), + extendedKeyUsages: z.unknown().nullable().optional(), + algorithms: z.unknown().nullable().optional(), + validity: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateTemplatesV2 = z.infer; +export type TPkiCertificateTemplatesV2Insert = Omit, TImmutableDBKeys>; +export type TPkiCertificateTemplatesV2Update = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts new file mode 100644 index 000000000..4a3b16eeb --- /dev/null +++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts @@ -0,0 +1,25 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiEstEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), + hashedPassphrase: z.string(), + encryptedCaChain: zodBuffer.nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiEstEnrollmentConfigs = z.infer; +export type TPkiEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiEstEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 33ebe910d..d5876782d 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -8,6 +8,35 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => { + try { + // Try to find as profile first using internal access + await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + return "profile"; + } catch (profileError) { + try { + await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + return "template"; + } catch (templateError) { + server.log.debug( + { + identifier, + profileError: profileError instanceof Error ? profileError.message : "Unknown error", + templateError: templateError instanceof Error ? templateError.message : "Unknown error" + }, + "EST identifier not found as profile or template" + ); + return null; + } + } + }; + // add support for CSR bodies server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { try { @@ -59,11 +88,29 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = return; } - const certificateTemplateId = urlFragments.slice(-2)[0]; - const estConfig = await server.services.certificateTemplate.getEstConfiguration({ - isInternal: true, - certificateTemplateId - }); + const identifier = urlFragments.slice(-2)[0]; + + const identifierType = await getIdentifierType(identifier); + if (!identifierType) { + res.raw.statusCode = 404; + res.raw.setHeader("Content-Type", "text/plain"); + res.raw.write("Certificate template or profile not found"); + res.raw.flushHeaders(); + return; + } + + let estConfig; + if (identifierType === "profile") { + estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + } else { + estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + } if (!estConfig.isEnabled) { throw new BadRequestError({ @@ -95,14 +142,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simpleenroll", + url: "/:identifier/simpleenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -112,9 +159,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleEnrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleEnroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -122,14 +183,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simplereenroll", + url: "/:identifier/simplereenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -139,9 +200,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleReenrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleReenroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -149,13 +224,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "GET", - url: "/:certificateTemplateId/cacerts", + url: "/:identifier/cacerts", config: { rateLimit: readLimit }, schema: { params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -165,8 +240,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.getCaCertsByProfile({ + profileId: identifier + }); + } return server.services.certificateEst.getCaCerts({ - certificateTemplateId: req.params.certificateTemplateId + certificateTemplateId: identifier }); } }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bc50283d4..f3c95e434 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -352,9 +352,18 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + CREATE_CERTIFICATE_PROFILE = "create-certificate-profile", + UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile", + DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile", + GET_CERTIFICATE_PROFILE = "get-certificate-profile", + LIST_CERTIFICATE_PROFILES = "list-certificate-profiles", + ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", + SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", + ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -2512,46 +2521,6 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } -interface CreateCertificateTemplate { - type: EventType.CREATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface GetCertificateTemplate { - type: EventType.GET_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - -interface UpdateCertificateTemplate { - type: EventType.UPDATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface DeleteCertificateTemplate { - type: EventType.DELETE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -2598,6 +2567,138 @@ interface GetCertificateTemplateEstConfig { }; } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + projectId: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface ListCertificateTemplates { + type: EventType.LIST_CERTIFICATE_TEMPLATES; + metadata: { + projectId: string; + }; +} + +interface CreateCertificateProfile { + type: EventType.CREATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + projectId: string; + enrollmentType: string; + }; +} + +interface UpdateCertificateProfile { + type: EventType.UPDATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface DeleteCertificateProfile { + type: EventType.DELETE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface GetCertificateProfile { + type: EventType.GET_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface ListCertificateProfiles { + type: EventType.LIST_CERTIFICATE_PROFILES; + metadata: { + projectId: string; + }; +} + +interface IssueCertificateFromProfile { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + commonName: string; + profileName: string; + }; +} + +interface SignCertificateFromProfile { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + profileName: string; + commonName: string; + }; +} + +interface OrderCertificateFromProfile { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + orderId: string; + profileName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -4051,13 +4152,22 @@ export type Event = | LoadProjectKmsBackupEvent | OrgAdminAccessProjectEvent | OrgAdminBypassSSOEvent - | CreateCertificateTemplate - | UpdateCertificateTemplate - | GetCertificateTemplate - | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | CreateCertificateTemplate + | UpdateCertificateTemplate + | DeleteCertificateTemplate + | GetCertificateTemplate + | ListCertificateTemplates + | CreateCertificateProfile + | UpdateCertificateProfile + | DeleteCertificateProfile + | GetCertificateProfile + | ListCertificateProfiles + | IssueCertificateFromProfile + | SignCertificateFromProfile + | OrderCertificateFromProfile | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index f139ff2c1..d303859bb 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseSecretSyncs: false, enterpriseCertificateSyncs: false, enterpriseAppConnections: true, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2a3cf82cc..aba2c5e78 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -67,6 +67,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ fips: false, eventSubscriptions: false, machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, pam: false }); diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9cdcfcc3d..2276dcf36 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; + pkiLegacyTemplates: false; fips: false; eventSubscriptions: false; pam: false; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index b9cabe022..34876f739 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -5,6 +5,7 @@ import { ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, @@ -72,8 +73,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionPkiTemplateActions.Edit, ProjectPermissionPkiTemplateActions.Create, ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionPkiTemplateActions.IssueCert, - ProjectPermissionPkiTemplateActions.ListCerts + ProjectPermissionPkiTemplateActions.IssueCert, // deprecated + ProjectPermissionPkiTemplateActions.ListCerts // deprecated ], ProjectPermissionSub.CertificateTemplates ); @@ -99,6 +100,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates ); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionCertificateProfileActions.IssueCert + ], + ProjectPermissionSub.CertificateProfiles + ); + can( [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], ProjectPermissionSub.Commits @@ -443,6 +455,7 @@ const buildMemberPermissionRules = () => { // double check if all CRUD are needed for CA and Certificates can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can( [ @@ -454,7 +467,15 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete + ], + ProjectPermissionSub.CertificateProfiles + ); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d2c5b30db..bb62440c1 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -111,6 +111,14 @@ export enum ProjectPermissionPkiSubscriberActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -249,7 +257,8 @@ export enum ProjectPermissionSub { PamFolders = "pam-folders", PamResources = "pam-resources", PamAccounts = "pam-accounts", - PamSessions = "pam-sessions" + PamSessions = "pam-sessions", + CertificateProfiles = "certificate-profiles" } export type SecretSubjectFields = { @@ -438,7 +447,8 @@ export type ProjectPermissionSet = ProjectPermissionPamAccountActions, ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) ] - | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -1109,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8d1ae45bf..5a336fa11 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -57,6 +57,7 @@ export enum ApiDocsTags { PkiCertificateAuthorities = "PKI Certificate Authorities", PkiCertificates = "PKI Certificates", PkiCertificateTemplates = "PKI Certificate Templates", + PkiCertificateProfiles = "PKI Certificate Profiles", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4a73a650c..a805fa1be 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -167,11 +167,19 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +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 { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; +import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; @@ -1036,6 +1044,10 @@ export const registerRoutes = async ( const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db); + const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db); + const certificateProfileDAL = certificateProfileDALFactory(db); + const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); + const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1120,6 +1132,21 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL, + permissionService + }); + + const certificateProfileService = certificateProfileServiceFactory({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -2086,6 +2113,27 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateV3Service = certificateV3ServiceFactory({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService: internalCertificateAuthorityService, + permissionService + }); + + const certificateEstV3Service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2296,6 +2344,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + certificateV3: certificateV3Service, + certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, @@ -2303,6 +2353,8 @@ export const registerRoutes = async ( certificateAuthority: certificateAuthorityService, internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, + certificateTemplateV2: certificateTemplateV2Service, + certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, pit: pitService, diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts new file mode 100644 index 000000000..2292c3ba8 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -0,0 +1,506 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiCertificateProfilesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertStatus } from "@app/services/certificate/certificate-types"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; + +export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + body: z + .object({ + projectId: z.string().min(1), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.createProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug, + projectId: certificateProfile.projectId, + enrollmentType: certificateProfile.enrollmentType + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional(), + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfiles: PkiCertificateProfilesSchema.extend({ + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional() + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_PROFILES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateProfiles: profiles, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + projectId: z.string(), + status: z.string(), + name: z.string() + }) + .optional(), + certificateTemplate: z + .object({ + id: z.string(), + projectId: z.string(), + name: z.string(), + description: z.string().optional() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional(), + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + let result = certificateProfile; + + if (req.query.includeMetrics) { + const metrics = await server.services.certificateProfile.getProfileMetrics({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + expiringDays: req.query.expiringDays + }); + result = { ...certificateProfile, metrics }; + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.GET_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile: result }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + slug: z.string().min(1) + }), + querystring: z.object({ + projectId: z.string().min(1) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + slug: req.params.slug + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1).optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.updateProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.deleteProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/:id/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.nativeEnum(CertStatus).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string(), + serialNumber: z.string(), + cn: z.string(), + status: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + createdAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificates = await server.services.certificateProfile.getProfileCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + ...req.query + }); + + return { certificates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 17f564be4..5ff0e39c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.GET_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); @@ -121,7 +122,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl + ttl: certificateTemplate.ttl, + projectId: certificateTemplate.projectId } } }); @@ -184,9 +186,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, caId: certificateTemplate.caId, pkiCollectionId: certificateTemplate.pkiCollectionId as string, - name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, ttl: certificateTemplate.ttl @@ -230,7 +232,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.DELETE_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 89865b1a1..589a79721 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; +import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; @@ -146,6 +147,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts new file mode 100644 index 000000000..8a7189727 --- /dev/null +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -0,0 +1,367 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .refine( + (val) => { + if (!val) return true; + if (val.length < 2) return false; + const unit = val.slice(-1); + const number = val.slice(0, -1); + const digitRegex = new RE2("^\\d+$"); + return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number); + }, + { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + } + ) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: createCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, ...data } = req.body; + const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + projectId, + data + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + projectId: certificateTemplate.projectId + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificateTemplates: certificateTemplateV2ResponseSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_TEMPLATES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateTemplates: templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + body: updateCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); +}; diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index aade29bb7..db4ebb176 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,4 +1,5 @@ import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router"; import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; @@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts new file mode 100644 index 000000000..549310738 --- /dev/null +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); +}; + +const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; +}; + +export const registerCertificatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .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" + }), + response: { + 200: 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() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateRequestForService: CertificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + 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 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: req.body.profileId, + certificateRequest: mappedCertificateRequest + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + commonName: req.body.commonName || "", + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"), + 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(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).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" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + csr: req.body.csr, + validity: { + ttl: req.body.ttl + }, + 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, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: "" + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/order-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + 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"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + commonName: validateTemplateRegexField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).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" + }), + response: { + 200: z.object({ + orderId: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ), + authorizations: z.array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + 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 + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index d7fa94d6b..47c3c2cb8 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerCertificatesRouter } from "./certificates-router"; import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); + await server.register(registerCertificatesRouter, { prefix: "/certificates" }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.test.ts b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts new file mode 100644 index 000000000..870055711 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns"; + +describe("signatureAlgorithmToAlgCfg", () => { + describe("RSA algorithms", () => { + it("should handle RSA-SHA256 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + + it("should handle RSA-SHA384 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-384", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }); + }); + + it("should handle RSA-SHA256 with RSA_3072 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_3072); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }); + }); + + it("should handle RSA-SHA512 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-512", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + }); + + describe("ECDSA algorithms", () => { + it("should handle ECDSA-SHA256 with P-256 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with P-384 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + + it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); + + describe("hash format normalization", () => { + it("should normalize SHA256 to SHA-256", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should normalize SHA384 to SHA-384", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + expect(result.hash).toBe("SHA-384"); + }); + + it("should normalize SHA512 to SHA-512", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096); + expect(result.hash).toBe("SHA-512"); + }); + + it("should handle SHA1 format", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-1"); + }); + + it("should handle SHA224 format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-224"); + }); + + it("should handle case insensitive hash normalization", () => { + const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle already normalized hash formats", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle SHA-3 family hashes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA3-256"); + }); + }); + + describe("dynamic key algorithm support", () => { + it("should support future RSA key sizes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192"); + + expect(result.name).toBe("RSASSA-PKCS1-v1_5"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support future EC curves", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1"); + + expect(result.name).toBe("ECDSA"); + expect(result.namedCurve).toBe("P-521"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support EC_P384 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); +}); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 9991e462e..aff81dcc5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import * as x509 from "@peculiar/x509"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,6 +69,13 @@ export const parseDistinguishedName = (dn: string): TDNParts => { export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_3072: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }; case CertKeyAlgorithm.RSA_4096: return { name: "RSASSA-PKCS1-v1_5", @@ -99,6 +107,73 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { } }; +export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => { + // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. + if (!signatureAlgorithm || typeof signatureAlgorithm !== "string" || !signatureAlgorithm.includes("-")) { + throw new Error(`Invalid signature algorithm format: ${signatureAlgorithm}`); + } + + const [keyType, hashType] = signatureAlgorithm.split("-"); + + if (!keyType || !hashType) { + throw new Error(`Malformed signature algorithm: ${signatureAlgorithm}`); + } + + const normalizeHashType = (hash: string) => { + const upperHash = hash.toUpperCase(); + + if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1"; + + if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224"; + if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256"; + if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384"; + if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512"; + + if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224"; + if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256"; + if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384"; + if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512"; + + throw new Error(`Unsupported hash algorithm: ${hash}`); + }; + + const normalizedHash = hashType ? normalizeHashType(hashType) : undefined; + + switch (keyType) { + case "RSA": + return { + name: "RSASSA-PKCS1-v1_5", + hash: normalizedHash || "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: + keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : keyAlgorithm === CertKeyAlgorithm.RSA_3072 ? 3072 : 2048 + }; + case "ECDSA": + // eslint-disable-next-line no-case-declarations + const is384Curve = + keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384"; + // eslint-disable-next-line no-case-declarations + const is521Curve = keyAlgorithm === "EC_secp521r1" || keyAlgorithm === "EC_P521"; + // eslint-disable-next-line no-case-declarations + let namedCurve: string; + if (is521Curve) { + namedCurve = "P-521"; + } else if (is384Curve) { + namedCurve = "P-384"; + } else { + namedCurve = "P-256"; + } + return { + name: "ECDSA", + namedCurve, + hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") + }; + default: + // Fallback to key algorithm default + return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + } +}; + /** * Return the public and private key of CA with id [caId] * Note: credentials are returned as crypto.webcrypto.CryptoKey @@ -111,7 +186,8 @@ export const getCaCredentials = async ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); @@ -132,7 +208,7 @@ export const getCaCredentials = async ({ cipherTextBlob: caSecret.encryptedPrivateKey }); - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const alg = signatureAlgorithm || keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const skObj = crypto.nativeCrypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); const caPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts index 61d620156..5ecc50a4b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -18,7 +18,7 @@ export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick( export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) }); @@ -26,7 +26,7 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index ab89ea996..5b9cd78ee 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -32,6 +32,8 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, + CertSignatureAlgorithm, + CertSignatureType, CertStatus, TAltNameMapping } from "../../certificate/certificate-types"; @@ -48,7 +50,8 @@ import { getCaCertChains, getCaCredentials, keyAlgorithmToAlgCfg, - parseDistinguishedName + parseDistinguishedName, + signatureAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; @@ -1174,7 +1177,10 @@ export const internalCertificateAuthorityServiceFactory = ({ actor, actorOrgId, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm, + isFromProfile }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1221,7 +1227,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1277,13 +1283,43 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); - const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const effectiveKeyAlgorithm = + (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; + + const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]); + + if ( + (requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) || + (requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa + ? CertSignatureAlgorithm.RSA_SHA256.split("-")[0] + : isEcdsaCa + ? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] + : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + // Determine signing algorithm for certificate signing + const signingAlg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${commonName}`, keys: leafKeys, - signingAlgorithm: alg, + signingAlgorithm: keyGenAlg, extensions: [ // eslint-disable-next-line no-bitwise new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) @@ -1296,7 +1332,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: signingAlg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); @@ -1319,7 +1356,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // handle key usages let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; if (keyUsages === undefined && !certificateTemplate) { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } if (keyUsages === undefined && certificateTemplate) { @@ -1405,7 +1442,7 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, signingKey: caPrivateKey, publicKey: csrObj.publicKey, - signingAlgorithm: alg, + signingAlgorithm: signingAlg, extensions }); @@ -1517,7 +1554,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore, notAfter, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1563,7 +1602,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!dto.isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1622,7 +1661,29 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) + + const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA); + + if ( + (requestedKeyType === CertSignatureType.RSA && !isRsaCa) || + (requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm; + const alg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = new x509.Pkcs10CertificateRequest(csr); @@ -1671,7 +1732,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (csrKeyUsageExtension) { selectedKeyUsages = csrKeyUsages; } else { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = dto.isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } } diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index fadd7b88d..22cb86d28 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TProjectPermission } from "@app/lib/types"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -131,6 +136,9 @@ export type TIssueCertFromCaDTO = { notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: CertSignatureAlgorithm; + keyAlgorithm?: CertKeyAlgorithm; + isFromProfile?: boolean; } & Omit; export type TSignCertFromCaDTO = @@ -148,6 +156,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } | ({ isInternal: false; @@ -163,6 +174,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -184,6 +198,7 @@ export type TGetCaCredentialsDTO = { certificateAuthoritySecretDAL: Pick; projectDAL: Pick; kmsService: Pick; + signatureAlgorithm?: RsaHashedImportParams | EcKeyImportParams; }; export type TGetCaCertChainsDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts new file mode 100644 index 000000000..bbd589110 --- /dev/null +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -0,0 +1,187 @@ +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + +export enum CertKeyUsageType { + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" +} + +export enum CertExtendedKeyUsageType { + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + OCSP_SIGNING = "ocsp_signing", + TIME_STAMPING = "time_stamping" +} + +export enum CertIncludeType { + MANDATORY = "mandatory", + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +export enum CertDurationUnit { + DAYS = "days", + MONTHS = "months", + YEARS = "years" +} + +export enum CertSubjectAttributeType { + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" +} + +export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => { + switch (usage) { + case CertKeyUsageType.DIGITAL_SIGNATURE: + return "digitalSignature"; + case CertKeyUsageType.KEY_ENCIPHERMENT: + return "keyEncipherment"; + case CertKeyUsageType.NON_REPUDIATION: + return "nonRepudiation"; + case CertKeyUsageType.DATA_ENCIPHERMENT: + return "dataEncipherment"; + case CertKeyUsageType.KEY_AGREEMENT: + return "keyAgreement"; + case CertKeyUsageType.KEY_CERT_SIGN: + return "keyCertSign"; + case CertKeyUsageType.CRL_SIGN: + return "cRLSign"; + case CertKeyUsageType.ENCIPHER_ONLY: + return "encipherOnly"; + case CertKeyUsageType.DECIPHER_ONLY: + return "decipherOnly"; + default: + return usage; + } +}; + +export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => { + switch (usage) { + case "digitalSignature": + case "digital_signature": + return CertKeyUsageType.DIGITAL_SIGNATURE; + case "keyEncipherment": + case "key_encipherment": + return CertKeyUsageType.KEY_ENCIPHERMENT; + case "nonRepudiation": + case "non_repudiation": + return CertKeyUsageType.NON_REPUDIATION; + case "dataEncipherment": + case "data_encipherment": + return CertKeyUsageType.DATA_ENCIPHERMENT; + case "keyAgreement": + case "key_agreement": + return CertKeyUsageType.KEY_AGREEMENT; + case "keyCertSign": + case "key_cert_sign": + return CertKeyUsageType.KEY_CERT_SIGN; + case "cRLSign": + case "crl_sign": + return CertKeyUsageType.CRL_SIGN; + case "encipherOnly": + case "encipher_only": + return CertKeyUsageType.ENCIPHER_ONLY; + case "decipherOnly": + case "decipher_only": + return CertKeyUsageType.DECIPHER_ONLY; + default: + throw new Error(`Unknown key usage: ${usage}`); + } +}; + +export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => { + switch (usage) { + case CertExtendedKeyUsageType.CLIENT_AUTH: + return "clientAuth"; + case CertExtendedKeyUsageType.SERVER_AUTH: + return "serverAuth"; + case CertExtendedKeyUsageType.CODE_SIGNING: + return "codeSigning"; + case CertExtendedKeyUsageType.EMAIL_PROTECTION: + return "emailProtection"; + case CertExtendedKeyUsageType.OCSP_SIGNING: + return "ocspSigning"; + case CertExtendedKeyUsageType.TIME_STAMPING: + return "timeStamping"; + default: + return usage; + } +}; + +export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => { + switch (usage) { + case "clientAuth": + case "client_auth": + return CertExtendedKeyUsageType.CLIENT_AUTH; + case "serverAuth": + case "server_auth": + return CertExtendedKeyUsageType.SERVER_AUTH; + case "codeSigning": + case "code_signing": + return CertExtendedKeyUsageType.CODE_SIGNING; + case "emailProtection": + case "email_protection": + return CertExtendedKeyUsageType.EMAIL_PROTECTION; + case "ocspSigning": + case "ocsp_signing": + return CertExtendedKeyUsageType.OCSP_SIGNING; + case "timeStamping": + case "time_stamping": + return CertExtendedKeyUsageType.TIME_STAMPING; + default: + throw new Error(`Unknown extended key usage: ${usage}`); + } +}; + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" +} + +export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); +export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); +export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); +export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType); +export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); +export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); +export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm); +export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts new file mode 100644 index 000000000..b88f183db --- /dev/null +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -0,0 +1,198 @@ +import RE2 from "re2"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + mapExtendedKeyUsageToLegacy, + mapKeyUsageToLegacy, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "./certificate-constants"; + +interface CertificateRequestInput { + keyUsages?: string[]; + extendedKeyUsages?: string[]; +} + +export const mapEnumsForValidation = (request: T): T => { + const mapKeyUsage = (usage: string): string => { + try { + return mapLegacyKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + const mapExtendedKeyUsage = (usage: string): string => { + try { + return mapLegacyExtendedKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + return { + ...request, + keyUsages: request.keyUsages?.map(mapKeyUsage), + extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage) + } as T; +}; + +export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => { + if (!date) return undefined; + return date instanceof Date ? date.toISOString() : date; +}; + +export const bufferToString = (data: Buffer | string): string => { + return String(data); +}; + +export const buildCertificateSubjectFromTemplate = ( + request: Record, + templateAttributes?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): Record => { + const subject: Record = {}; + const attributeMap: Record = { + common_name: "commonName", + organization: "organization", + country: "country" + }; + + if (!templateAttributes || templateAttributes.length === 0) { + return subject; + } + + templateAttributes.forEach((attr) => { + const requestKey = attributeMap[attr.type]; + const value = request[requestKey]; + + if (value && typeof value === "string" && (attr.allowed || attr.required)) { + subject[attr.type] = value; + } + }); + + return subject; +}; + +const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); +}; + +const createWildcardRegex = (pattern: string): RE2 => { + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = pattern.replace(escapeRegex, "\\$&"); + const wildcardRegex = new RE2(/\*/g); + const regexPattern = escaped.replace(wildcardRegex, ".*"); + return new RE2(`^${regexPattern}$`); +}; + +const validateValueAgainstPatterns = (value: string, patterns: string[]): boolean => { + if (!patterns || patterns.length === 0) { + return false; + } + + for (const pattern of patterns) { + if (isWildcardPattern(pattern)) { + try { + const regex = createWildcardRegex(pattern); + if (regex.test(value)) { + return true; + } + } catch { + if (pattern === value) { + return true; + } + } + } else if (pattern === value) { + return true; + } + } + + return false; +}; + +export const buildSubjectAlternativeNamesFromTemplate = ( + request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, + templateSans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): string => { + if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { + return ""; + } + + if (!templateSans || templateSans.length === 0) { + return request.subjectAlternativeNames.map((san) => san.value).join(","); + } + + const allowedSans: string[] = []; + + request.subjectAlternativeNames.forEach((san) => { + const templateSan = templateSans.find((template) => template.type === san.type); + + if (!templateSan) { + allowedSans.push(san.value); + return; + } + + if (templateSan.denied && validateValueAgainstPatterns(san.value, templateSan.denied)) { + throw new Error(`SAN value '${san.value}' is explicitly denied for type '${san.type}'`); + } + + const isRequired = templateSan.required && validateValueAgainstPatterns(san.value, templateSan.required); + const isAllowed = templateSan.allowed && validateValueAgainstPatterns(san.value, templateSan.allowed); + + if (isRequired || isAllowed || (!templateSan.allowed && !templateSan.required)) { + allowedSans.push(san.value); + } else { + throw new Error(`SAN value '${san.value}' is not allowed for type '${san.type}'`); + } + }); + + return allowedSans.join(","); +}; + +export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => { + return mapLegacyKeyUsageToStandard(usage); +}; + +export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => { + return mapKeyUsageToLegacy(usage) as CertKeyUsage; +}; + +export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => { + return mapLegacyExtendedKeyUsageToStandard(usage); +}; + +export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => { + return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage; +}; + +export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => { + return usages?.map(convertLegacyKeyUsage); +}; + +export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => { + return usages?.map(convertToLegacyKeyUsage); +}; + +export const convertExtendedKeyUsageArrayFromLegacy = ( + usages?: CertExtendedKeyUsage[] +): CertExtendedKeyUsageType[] | undefined => { + return usages?.map(convertLegacyExtendedKeyUsage); +}; + +export const convertExtendedKeyUsageArrayToLegacy = ( + usages?: CertExtendedKeyUsageType[] +): CertExtendedKeyUsage[] | undefined => { + return usages?.map(convertToLegacyExtendedKeyUsage); +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts new file mode 100644 index 000000000..b6408a986 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts @@ -0,0 +1,737 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-bitwise */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service"; + +// Mock the x509 module +vi.mock("@peculiar/x509", () => ({ + Pkcs10CertificateRequest: vi.fn(), + GeneralNames: vi.fn(), + KeyUsagesExtension: vi.fn(), + ExtendedKeyUsageExtension: vi.fn(), + X509Certificate: vi.fn(), + KeyUsageFlags: { + digitalSignature: 1, + nonRepudiation: 2, + keyEncipherment: 4, + dataEncipherment: 8, + keyAgreement: 16, + keyCertSign: 32, + cRLSign: 64, + encipherOnly: 128, + decipherOnly: 256 + } +})); + +// Mock other dependencies +vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({ + parseDistinguishedName: vi.fn((subject: string) => { + const parts = subject.split(","); + const result: any = {}; + parts.forEach((part) => { + const [key, value] = part.split("="); + switch (key.trim()) { + case "CN": + result.commonName = value; + break; + case "O": + result.organization = value; + break; + case "OU": + result.ou = value; + break; + case "L": + result.locality = value; + break; + case "ST": + result.province = value; + break; + case "C": + result.country = value; + break; + default: + break; + } + }); + return result; + }) +})); + +vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({ + validateAndMapAltNameType: vi.fn((value: string) => { + if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "dns", value }; + } + if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "ip", value }; + } + return null; + }) +})); + +vi.mock("@app/services/certificate-common/certificate-constants", () => ({ + mapLegacyKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + digitalSignature: "digital_signature", + keyEncipherment: "key_encipherment", + keyCertSign: "key_cert_sign" + }; + return mapping[usage] || usage; + }), + mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + clientAuth: "client_auth", + serverAuth: "server_auth", + codeSigning: "code_signing" + }; + return mapping[usage] || usage; + }), + CertKeyUsageType: { + DIGITAL_SIGNATURE: "digital_signature", + KEY_ENCIPHERMENT: "key_encipherment", + KEY_CERT_SIGN: "key_cert_sign" + }, + CertExtendedKeyUsageType: { + CLIENT_AUTH: "client_auth", + SERVER_AUTH: "server_auth", + CODE_SIGNING: "code_signing" + }, + CertSubjectAlternativeNameType: { + DNS_NAME: "dns_name", + IP_ADDRESS: "ip_address", + RFC822_NAME: "rfc822_name", + UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier" + } +})); + +vi.mock("@app/services/certificate/certificate-types", () => ({ + mapLegacyAltNameType: vi.fn((type: string) => { + const mapping: Record = { + dns: "dns_name", + ip: "ip_address", + email: "rfc822_name", + url: "uniform_resource_identifier" + }; + return mapping[type] || type; + }), + TAltNameType: { + EMAIL: "email", + DNS: "dns", + IP: "ip", + URL: "url" + }, + CertExtendedKeyUsageOIDToName: { + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning" + }, + CertKeyUsage: { + DIGITAL_SIGNATURE: "digitalSignature", + KEY_ENCIPHERMENT: "keyEncipherment", + KEY_CERT_SIGN: "keyCertSign", + NON_REPUDIATION: "nonRepudiation", + DATA_ENCIPHERMENT: "dataEncipherment", + KEY_AGREEMENT: "keyAgreement", + CRL_SIGN: "cRLSign", + ENCIPHER_ONLY: "encipherOnly", + DECIPHER_ONLY: "decipherOnly" + }, + CertExtendedKeyUsage: { + CLIENT_AUTH: "clientAuth", + SERVER_AUTH: "serverAuth", + CODE_SIGNING: "codeSigning" + } +})); + +vi.mock("@app/services/certificate-common/certificate-utils", () => ({ + mapEnumsForValidation: vi.fn((req: any) => req) +})); + +vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({ + convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response") +})); + +describe("CertificateEstV3Service Security Fix", () => { + let service: TCertificateEstV3ServiceFactory; + + const mockInternalCertificateAuthorityService = { + signCertFromCa: vi.fn() + }; + + const mockCertificateTemplateV2Service = { + validateCertificateRequest: vi.fn() + }; + + const mockCertificateAuthorityDAL = { + findById: vi.fn(), + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateAuthorityCertDAL = { + find: vi.fn(), + findById: vi.fn() + }; + + const mockProjectDAL = { + findOne: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn() + }; + + const mockKmsService = { + decryptWithKmsKey: vi.fn(), + generateKmsKey: vi.fn() + }; + + const mockLicenseService = { + getPlan: vi.fn() + }; + + const mockCertificateProfileDAL = { + findByIdWithConfigs: vi.fn() + }; + + const mockEstEnrollmentConfigDAL = { + findById: vi.fn() + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + caId: "ca-123", + certificateTemplateId: "template-v2-123", + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }; + + const mockEstConfig = { + id: "est-config-123", + disableBootstrapCaValidation: true + }; + + const mockProject = { + id: "project-123", + orgId: "org-123" + }; + + const mockPlan = { + pkiEst: true + }; + + beforeEach(async () => { + const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509"); + + service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService: mockInternalCertificateAuthorityService, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + projectDAL: mockProjectDAL, + kmsService: mockKmsService, + licenseService: mockLicenseService, + certificateProfileDAL: mockCertificateProfileDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL + }); + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig); + mockProjectDAL.findOne.mockResolvedValue(mockProject); + mockLicenseService.getPlan.mockResolvedValue(mockPlan); + + // Set up the default CSR parsing behavior + (Pkcs10CertificateRequest as any).mockImplementation((csr: string) => { + const parsed = JSON.parse(csr); + const mockExtensions: any[] = []; + + if (parsed.sans && parsed.sans.length > 0) { + mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" }); + } + + return { + subject: parsed.subject, + extensions: mockExtensions, + getExtension: vi.fn((oid: string) => { + if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) { + // Calculate usages as bitwise OR of key usage flags + let usages = 0; + parsed.keyUsages.forEach((usage: string) => { + switch (usage) { + case "digital_signature": + usages |= 1; // KeyUsageFlags.digitalSignature + break; + case "key_encipherment": + usages |= 4; // KeyUsageFlags.keyEncipherment + break; + case "key_cert_sign": + usages |= 32; // KeyUsageFlags.keyCertSign + break; + default: + break; + } + }); + return { usages }; + } + if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) { + const ekuOids = parsed.extendedKeyUsages.map((eku: string) => { + switch (eku) { + case "client_auth": + return "1.3.6.1.5.5.7.3.2"; + case "server_auth": + return "1.3.6.1.5.5.7.3.1"; + case "code_signing": + return "1.3.6.1.5.5.7.3.3"; + default: + return "1.3.6.1.5.5.7.3.1"; + } + }); + return { usages: ekuOids }; + } + return undefined; + }) + }; + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const createMockCSR = ( + options: { + subject?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + sans?: Array<{ type: string; value: string }>; + } = {} + ) => { + const { + subject = "CN=test.example.com,O=Test Org,C=US", + keyUsages = [], + extendedKeyUsages = [], + sans = [] + } = options; + + return JSON.stringify({ + subject, + keyUsages, + extendedKeyUsages, + sans + }); + }; + + describe("CSR Extraction and Template Validation", () => { + it("should extract subject attributes from CSR", async () => { + const csr = createMockCSR({ + subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US" + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "test.example.com", + organization: "Test Organization", + organizationUnit: "IT Department", + locality: "San Francisco", + state: "California", + country: "US" + }) + ); + }); + + it("should extract key usages from CSR", async () => { + const csr = createMockCSR({ + keyUsages: ["digital_signature", "key_encipherment"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"]) + }) + ); + }); + + it("should extract extended key usages from CSR", async () => { + const csr = createMockCSR({ + extendedKeyUsages: ["client_auth", "server_auth"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"]) + }) + ); + }); + + it("should extract Subject Alternative Names from CSR", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + const csr = createMockCSR({ + sans: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + subjectAlternativeNames: expect.arrayContaining([ + expect.objectContaining({ + type: "dns_name", + value: "test.example.com" + }), + expect.objectContaining({ + type: "ip_address", + value: "192.168.1.1" + }) + ]) + }) + ); + }); + }); + + describe("Template Validation Enforcement", () => { + const basicCSR = createMockCSR(); + + it("should enforce template validation and reject invalid requests", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + + it("should allow valid requests that pass template validation", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.any(Object) + ); + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR, + isFromProfile: true + }); + }); + + it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value 'evil.com' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled(); + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + }); + + describe("Policy Bypass Prevention", () => { + const maliciousCSR = createMockCSR({ + subject: "CN=evil.com,O=Evil Corp,C=XX", + keyUsages: ["key_cert_sign"], + sans: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + }); + + it("should block attempts to bypass subject attribute policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "evil.com", + organization: "Evil Corp", + country: "XX" + }) + ); + }); + + it("should block attempts to bypass key usage policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["key_cert_sign"]) + }) + ); + }); + + it("should block attempts to bypass SAN policies", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + }); + + describe("Error Handling", () => { + const basicCSR = createMockCSR(); + + it("should handle profile not found", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "nonexistent", + sslClientCert: "" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should handle non-EST enrollment type", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({ + ...mockProfile, + enrollmentType: EnrollmentType.API + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + + it("should handle template validation service errors", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue( + new Error("Template validation service unavailable") + ); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow("Template validation service unavailable"); + }); + }); + + describe("Integration with existing flow", () => { + const basicCSR = createMockCSR(); + + beforeEach(() => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + }); + + it("should call internal CA service with correct parameters after validation", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + isFromProfile: true, + csr: basicCSR + }); + }); + + it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + // Verify it uses caId from profile, not certificateTemplateId + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.objectContaining({ + caId: "ca-123" + }) + ); + + // Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.not.objectContaining({ + certificateTemplateId: expect.anything() + }) + ); + }); + }); +}); diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts new file mode 100644 index 000000000..f6dbfba52 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -0,0 +1,408 @@ +import * as x509 from "@peculiar/x509"; + +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { isCertChainValid } from "@app/services/certificate/certificate-fns"; +import { + CertExtendedKeyUsageOIDToName, + CertKeyUsage, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { + getCaCertChain, + getCaCertChains, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; +import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns"; +import { TLicenseServiceFactory } from "../../ee/services/license/license-service"; + +type TCertificateEstV3ServiceFactoryDep = { + internalCertificateAuthorityService: Pick; + certificateTemplateV2Service: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + licenseService: Pick; + certificateProfileDAL: Pick; + estEnrollmentConfigDAL: Pick; +}; + +export type TCertificateEstV3ServiceFactory = ReturnType; + +export const certificateEstV3ServiceFactory = ({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL +}: TCertificateEstV3ServiceFactoryDep) => { + const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; + }; + const simpleEnrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + if (!estConfig.disableBootstrapCaValidation) { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = estConfig.encryptedCaChain + ? ( + await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }) + ).toString() + : ""; + + const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); + + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const simpleReenrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const cert = new x509.X509Certificate(leafCertificate); + const caCertChains = await getCaCertChains({ + caId: profile.caId, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService + }); + + const verifiedChains = await Promise.all( + caCertChains.map((chain) => { + const caCert = new x509.X509Certificate(chain.certificate); + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; + + return isCertChainValid([cert, caCert, ...caChain]); + }) + ); + + if (!verifiedChains.some(Boolean)) { + throw new BadRequestError({ + message: "Invalid client certificate: unable to build a valid certificate chain" + }); + } + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + if (csrObj.subject !== cert.subject) { + throw new BadRequestError({ + message: "Subject mismatch" + }); + } + + let csrSanSet: Set = new Set(); + const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (csrSanExtension) { + const sanNames = new x509.GeneralNames(csrSanExtension.value); + csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + let certSanSet: Set = new Set(); + const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (certSanExtension) { + const sanNames = new x509.GeneralNames(certSanExtension.value); + certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) { + throw new BadRequestError({ + message: "Subject alternative names mismatch" + }); + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ + message: `Internal Certificate Authority with ID '${profile.caId}' not found` + }); + } + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId as string, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + let certificates: x509.X509Certificate[] = []; + if (caCertChain && caCertChain.trim()) { + try { + certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); + } catch (error) { + certificates = []; + } + } + + const caCertificate = new x509.X509Certificate(caCert); + + return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); + }; + + return { + simpleEnrollByProfile, + simpleReenrollByProfile, + getCaCertsByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts new file mode 100644 index 000000000..20cb9f3bc --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -0,0 +1,552 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +export type TCertificateProfileDALFactory = ReturnType; + +export const certificateProfileDALFactory = (db: TDbClient) => { + const certificateProfileOrm = ormify(db, TableName.PkiCertificateProfile); + + const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ + TCertificateProfile + ]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate profile" }); + } + }; + + const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .update(data) + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const deleteById = async (id: string, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .del() + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate profile" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as + | TCertificateProfile + | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id" }); + } + }; + + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { + try { + const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.PkiCertificateTemplateV2, + `${TableName.PkiCertificateProfile}.certificateTemplateId`, + `${TableName.PkiCertificateTemplateV2}.id` + ) + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("id").withSchema(TableName.PkiCertificateTemplateV2).as("templateId"), + db.ref("projectId").withSchema(TableName.PkiCertificateTemplateV2).as("templateProjectId"), + db.ref("name").withSchema(TableName.PkiCertificateTemplateV2).as("templateName"), + db.ref("description").withSchema(TableName.PkiCertificateTemplateV2).as("templateDescription"), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estConfigDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + ) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first(); + + const result = await query; + + if (!result) return undefined; + + const estConfig = + result.estConfigId && result.estConfigHashedPassphrase + ? ({ + id: result.estConfigId, + disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : "" + } as TCertificateProfileWithConfigs["estConfig"]) + : undefined; + + const apiConfig = result.apiConfigId + ? ({ + id: result.apiConfigId, + autoRenew: !!result.apiConfigAutoRenew, + autoRenewDays: result.apiConfigAutoRenewDays || undefined + } as TCertificateProfileWithConfigs["apiConfig"]) + : undefined; + + const certificateAuthority = + result.caId && result.caProjectId && result.caStatus && result.caName + ? ({ + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]) + : undefined; + + const certificateTemplate = + result.templateId && result.templateProjectId && result.templateName + ? ({ + id: result.templateId, + projectId: result.templateProjectId, + name: result.templateName, + description: result.templateDescription || undefined + } as TCertificateProfileWithConfigs["certificateTemplate"]) + : undefined; + + const transformedResult: TCertificateProfileWithConfigs = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig, + certificateAuthority, + certificateTemplate + }; + + return transformedResult; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with configs" }); + } + }; + + const findBySlugAndProjectId = async ( + slug: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + } = {}, + tx?: Knex + ): Promise => { + try { + const { + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 7 + } = options; + + let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( + `${TableName.PkiCertificateProfile}.projectId`, + projectId + ); + + if (search) { + baseQuery = baseQuery.where((builder) => { + void builder.where((qb) => { + void qb + .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) + .orWhereILike(`${TableName.PkiCertificateProfile}.description`, `%${search}%`); + }); + }); + } + + if (enrollmentType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); + } + + if (caId) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); + } + + let query = baseQuery + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + ); + + if (includeMetrics) { + query = query.leftJoin( + TableName.Certificate, + `${TableName.PkiCertificateProfile}.id`, + `${TableName.Certificate}.profileId` + ); + + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + query = query + .select( + selectAllTableCols(TableName.PkiCertificateProfile), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + db.raw("COUNT(certificates.id) as total_certificates"), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', + [expiringDate] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates', + [now] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .groupBy( + `${TableName.PkiCertificateProfile}.id`, + `${TableName.PkiEstEnrollmentConfig}.id`, + `${TableName.PkiApiEnrollmentConfig}.id` + ); + } + + const results = (await query + .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") + .offset(offset) + .limit(limit)) as Record[]; + + return results.map((result: Record) => { + const estConfig = + result.estId && result.estHashedPassphrase + ? { + id: result.estId as string, + disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : "" + } + : undefined; + + const apiConfig = result.apiId + ? { + id: result.apiId as string, + autoRenew: !!result.apiAutoRenew, + autoRenewDays: (result.apiAutoRenewDays as number) || undefined + } + : undefined; + + const baseProfile = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig + }; + + if (includeMetrics) { + return { + ...baseProfile, + total_certificates: result.total_certificates, + active_certificates: result.active_certificates, + expired_certificates: result.expired_certificates, + expiring_certificates: result.expiring_certificates, + revoked_certificates: result.revoked_certificates + } as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs; + } + + return baseProfile as TCertificateProfileWithConfigs; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { search, enrollmentType, caId } = options; + + let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`); + }); + }); + } + + if (enrollmentType) { + query = query.where({ enrollmentType }); + } + + if (caId) { + query = query.where({ caId }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate profiles by project id" }); + } + }; + + const findByNameAndProjectId = async ( + name: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug: name, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by name and project id" }); + } + }; + + const getCertificatesByProfile = async ( + profileId: string, + options: { + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { offset = 0, limit = 20, status, search } = options; + const now = new Date(); + + let query = (tx || db)(TableName.Certificate).where("profileId", profileId); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`); + }); + }); + } + + if (status) { + switch (status) { + case "active": + query = query.where("notAfter", ">", now).whereNull("revokedAt"); + break; + case "expired": + query = query.where("notAfter", "<=", now).whereNull("revokedAt"); + break; + case "revoked": + query = query.whereNotNull("revokedAt"); + break; + default: + break; + } + } + + const certificates = await query + .select((tx || db).ref("id").withSchema(TableName.Certificate)) + .select((tx || db).ref("serialNumber").withSchema(TableName.Certificate)) + .select((tx || db).ref("cn").withSchema(TableName.Certificate)) + .select((tx || db).ref("status").withSchema(TableName.Certificate)) + .select((tx || db).ref("notBefore").withSchema(TableName.Certificate)) + .select((tx || db).ref("notAfter").withSchema(TableName.Certificate)) + .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate)) + .select((tx || db).ref("createdAt").withSchema(TableName.Certificate)) + .orderBy("createdAt", "desc") + .offset(offset) + .limit(limit); + + return certificates.map((cert) => ({ + ...cert, + revokedAt: cert.revokedAt ?? null + })); + } catch (error) { + throw new DatabaseError({ error, name: "Get certificates by profile" }); + } + }; + + const getProfileMetrics = async ( + profileId: string, + expiringDays: number = 7, + tx?: Knex + ): Promise => { + try { + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + const metrics = await (tx || db)(TableName.Certificate) + .where("profileId", profileId) + .select( + db.raw("COUNT(*) as total_certificates"), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [ + expiringDate + ]), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]), + db.raw( + 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .first(); + + return { + profileId, + totalCertificates: parseInt(String((metrics as Record)?.total_certificates || 0), 10), + activeCertificates: parseInt(String((metrics as Record)?.active_certificates || 0), 10), + expiredCertificates: parseInt(String((metrics as Record)?.expired_certificates || 0), 10), + expiringCertificates: parseInt(String((metrics as Record)?.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String((metrics as Record)?.revoked_certificates || 0), 10) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate profile metrics" }); + } + }; + + const isProfileInUse = async (profileId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); + + return parseInt((doc as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate profile is in use" }); + } + }; + + return { + ...certificateProfileOrm, + create, + updateById, + deleteById, + findById, + findByIdWithConfigs, + findBySlugAndProjectId, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + getCertificatesByProfile, + getProfileMetrics, + isProfileInUse + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts new file mode 100644 index 000000000..7b3e2cc57 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -0,0 +1,134 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EnrollmentType } from "./certificate-profile-types"; + +export const createCertificateProfileSchema = z + .object({ + projectId: z.string().uuid("Project ID must be valid"), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ); + +export const updateCertificateProfileSchema = z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ); + +export const getCertificateProfileByIdSchema = z.object({ + id: z.string().uuid() +}); + +export const getCertificateProfileBySlugSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + slug: z.string().min(1) +}); + +export const listCertificateProfilesSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional() +}); + +export const deleteCertificateProfileSchema = z.object({ + id: z.string().uuid() +}); + +export const listCertificatesByProfileSchema = z.object({ + profileId: z.string().uuid(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.enum(["active", "expired", "revoked"]).optional(), + search: z.string().optional() +}); + +export const getCertificateProfileMetricsSchema = z.object({ + profileId: z.string().uuid(), + expiringDays: z.coerce.number().min(1).max(365).default(30) +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts new file mode 100644 index 000000000..dd2d7d2d6 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -0,0 +1,1181 @@ +/* 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 */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import type { TKmsServiceFactory } from "../kms/kms-service"; +import type { TProjectDALFactory } from "../project/project-dal"; +import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; +import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; + +vi.mock("@app/lib/crypto/cryptography", () => ({ + crypto: { + hashing: () => ({ + createHash: vi.fn().mockResolvedValue("mocked-hash") + }), + generateRandomPassword: vi.fn().mockReturnValue("mocked-password") + } +})); + +vi.mock("@app/lib/config/env", () => ({ + getConfig: () => ({ + SALT_ROUNDS: 12 + }) +})); + +describe("CertificateProfileService", () => { + let service: TCertificateProfileServiceFactory; + + const mockCertificateProfileDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findBySlugAndProjectId: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + findByNameAndProjectId: vi.fn(), + findByIdWithConfigs: vi.fn(), + getCertificatesByProfile: vi.fn(), + getProfileMetrics: vi.fn(), + isProfileInUse: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateProfileDALFactory; + + const mockCertificateTemplateV2DAL = { + findById: vi.fn(), + create: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateTemplateV2DALFactory; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const sampleProfile: TCertificateProfile = { + id: "profile-123", + projectId: "project-123", + description: "Test certificate profile", + slug: "test-profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const sampleProfileWithConfigs: TCertificateProfileWithConfigs = { + ...sampleProfile, + certificateAuthority: { + id: "ca-123", + projectId: "project-123", + status: "active", + name: "Test CA" + }, + certificateTemplate: { + id: "template-123", + projectId: "project-123", + name: "Test Template", + description: "Test template" + }, + apiConfig: { + id: "api-config-123", + autoRenew: true, + autoRenewDays: 30 + } + }; + + const sampleTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template" + }; + + const mockApiEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "api-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + findProfilesForAutoRenewal: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TApiEnrollmentConfigDALFactory; + + const mockEstEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "est-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TEstEnrollmentConfigDALFactory; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn() + } + }) + } as unknown as Pick; + + const mockKmsService = { + encryptWithKmsKey: vi + .fn() + .mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })), + decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))), + generateKmsKey: vi.fn() + } as unknown as Pick; + + const mockProjectDAL = { + findById: vi.fn(), + findOne: vi.fn(), + updateById: vi.fn(), + findProjectBySlug: vi.fn(), + transaction: vi.fn() + } as unknown as Pick; + + beforeEach(() => { + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Mock the transaction method to execute the callback and return the result + (mockCertificateProfileDAL.transaction as any).mockImplementation(async (fn: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await + return await fn(); + }); + + service = certificateProfileServiceFactory({ + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2DAL: mockCertificateTemplateV2DAL, + apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + permissionService: mockPermissionService, + kmsService: mockKmsService, + projectDAL: mockProjectDAL + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("createProfile", () => { + const validProfileData = { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + beforeEach(() => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should create profile successfully", async () => { + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); + expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith( + { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + projectId: "project-123" + }, + undefined + ); + }); + + it("should throw NotFoundError when certificate template not found", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template belongs to different project", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue({ + ...sampleTemplate, + projectId: "different-project" + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError when profile slug already exists", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for EST enrollment without EST config", async () => { + const invalidData = { + ...validProfileData, + enrollmentType: EnrollmentType.EST, + estConfigId: null + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for API enrollment without API config", async () => { + const invalidData = { + slug: "invalid-profile", + description: "Invalid test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123" + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should create profile with API enrollment", async () => { + const apiProfileData = { + slug: "api-profile", + description: "Profile with API enrollment", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: apiProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("updateProfile", () => { + const updateData = { + slug: "updated-profile", + description: "Updated description" + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...sampleProfile, + ...updateData, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should update profile successfully", async () => { + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }); + + expect(result.slug).toBe("updated-profile"); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should validate certificate template when updating", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + + const updateWithTemplate = { + ...updateData, + certificateTemplateId: "template-123" + }; + + await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateWithTemplate + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("getProfileById", () => { + it("should return profile successfully", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileByIdWithConfigs", () => { + it("should return profile with configs successfully", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(sampleProfileWithConfigs); + + const result = await service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfileWithConfigs); + expect(mockCertificateProfileDAL.findByIdWithConfigs).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect( + service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileBySlug", () => { + it("should return profile by slug successfully", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "test-profile" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("test-profile", "project-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + + await expect( + service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "nonexistent" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listProfiles", () => { + const mockProfiles = [sampleProfile]; + + beforeEach(() => { + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfiles); + (mockCertificateProfileDAL.countByProjectId as any).mockResolvedValue(1); + }); + + it("should list profiles successfully", async () => { + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123" + }); + + expect(result.profiles).toEqual(mockProfiles); + expect(result.totalCount).toBe(1); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with filters", async () => { + await service.listProfiles({ + ...mockActor, + projectId: "project-123", + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123" + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with metrics when includeMetrics is true", async () => { + const mockProfilesWithMetrics = [ + { + ...sampleProfile, + total_certificates: 10, + active_certificates: 8, + expired_certificates: 1, + expiring_certificates: 1, + revoked_certificates: 0 + } + ]; + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics); + + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123", + includeMetrics: true, + expiringDays: 15 + }); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]).toHaveProperty("metrics"); + expect(result.profiles[0].metrics).toEqual({ + profileId: sampleProfile.id, + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 1, + revokedCertificates: 0 + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: true, + expiringDays: 15 + }); + }); + }); + + describe("deleteProfile", () => { + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + }); + + it("should delete profile successfully", async () => { + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileCertificates", () => { + const mockCertificates = [ + { + id: "cert-123", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(mockCertificates); + }); + + it("should get profile certificates successfully", async () => { + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockCertificates); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: undefined + }); + }); + + it("should get profile certificates with filters", async () => { + await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileMetrics", () => { + const mockMetrics = { + profileId: "profile-123", + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 2, + revokedCertificates: 1 + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics); + }); + + it("should get profile metrics successfully", async () => { + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockMetrics); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30); + }); + + it("should get profile metrics with custom expiring days", async () => { + await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 60 + }); + + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("comprehensive certificate profile scenarios", () => { + describe("profile configuration validation", () => { + it("should validate EST enrollment configuration", async () => { + const estProfileData = { + slug: "est-profile", + description: "Profile with EST enrollment", + enrollmentType: EnrollmentType.EST, + caId: "ca-123", + certificateTemplateId: "template-123", + estConfig: { + disableBootstrapCaValidation: false, + passphrase: "secret-passphrase", + caChain: + "-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----" + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: estProfileData + }); + + expect(result.enrollmentType).toBe(EnrollmentType.EST); + expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, + hashedPassphrase: "mocked-hash", + encryptedCaChain: Buffer.from("encrypted-data") + }, + undefined + ); + }); + + it("should handle profile slug uniqueness validation", async () => { + vi.clearAllMocks(); + + const duplicateSlugData = { + slug: "different-profile-name", + description: "Profile with duplicate slug", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: duplicateSlugData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate auto-renewal configuration", async () => { + const autoRenewData = { + slug: "auto-renew-profile", + description: "Profile with auto-renewal", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 7 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + apiConfigId: "api-config-123", + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: autoRenewData + }); + + expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + autoRenew: true, + autoRenewDays: 7 + }, + undefined + ); + expect(result).toBeDefined(); + }); + }); + + describe("profile lifecycle management", () => { + it("should handle profile updates with enrollment type changes", async () => { + const currentProfile = { + ...sampleProfile, + enrollmentType: EnrollmentType.API, + apiConfigId: "api-config-123", + estConfigId: null + }; + + const updateToEst = { + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(currentProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...currentProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }); + + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateToEst + }); + + expect(mockEstEnrollmentConfigDAL.create).not.toHaveBeenCalled(); + expect(result.enrollmentType).toBe(EnrollmentType.EST); + }); + + it("should allow deletion of profiles", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + }); + + describe("certificate management", () => { + it("should filter certificates by status", async () => { + const activeCerts = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(activeCerts); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + status: "active" + }); + + expect(result).toEqual(activeCerts); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: "active", + search: undefined + }); + }); + + it("should search certificates by common name", async () => { + const searchResults = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "api.example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(searchResults); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + search: "api.example" + }); + + expect(result).toEqual(searchResults); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: "api.example" + }); + }); + }); + + describe("metrics and monitoring", () => { + it("should calculate profile metrics correctly", async () => { + const detailedMetrics = { + profileId: "profile-123", + totalCertificates: 50, + activeCertificates: 40, + expiredCertificates: 5, + expiringCertificates: 3, + revokedCertificates: 2 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123", + expiringDays: 14 + }); + + expect(result).toEqual(detailedMetrics); + expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14); + }); + + it("should handle zero certificate metrics", async () => { + const emptyMetrics = { + profileId: "profile-123", + totalCertificates: 0, + activeCertificates: 0, + expiredCertificates: 0, + expiringCertificates: 0, + revokedCertificates: 0 + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics); + + const result = await service.getProfileMetrics({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result.totalCertificates).toBe(0); + expect(result.activeCertificates).toBe(0); + }); + }); + + describe("error scenarios", () => { + it("should handle database connection errors gracefully", async () => { + (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow("Database connection failed"); + }); + + it("should handle invalid template reference during profile creation", async () => { + const profileData = { + slug: "invalid-template-profile", + description: "Profile with invalid template", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "nonexistent-template", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: profileData + }) + ).rejects.toThrow(NotFoundError); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("nonexistent-template"); + }); + + it("should handle concurrent profile creation conflicts", async () => { + const conflictingData = { + slug: "concurrent-profile", + description: "Profile created concurrently", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockRejectedValue(new Error("Unique constraint violation")); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: conflictingData + }) + ).rejects.toThrow("Unique constraint violation"); + }); + }); + + describe("permission and security", () => { + it("should validate project ownership for cross-project template access", async () => { + const crossProjectData = { + slug: "cross-project-profile", + description: "Profile using template from different project", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-456", + apiConfig: { + autoRenew: false + } + }; + + const foreignTemplate = { + id: "template-456", + projectId: "different-project-456", + slug: "foreign-template" + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: crossProjectData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate slug format constraints", async () => { + const invalidSlugData = { + slug: "invalid-slug-profile", + description: "Profile with invalid slug format", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + slug: invalidSlugData.slug, + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidSlugData + }); + + expect(result.slug).toBe(invalidSlugData.slug); + }); + }); + }); + + describe("getEstConfigurationByProfile", () => { + it("should return EST configuration for valid EST profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + const result = await service.getEstConfigurationByProfile({ ...mockActor, profileId }); + + expect(result).toEqual({ + orgId: "project-123", + isEnabled: true, + caChain: "mock-ca-chain", + disableBootstrapCertValidation: false, + hashedPassphrase: "" + }); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when profile is not configured for EST enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.API, // Wrong enrollment type + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + ForbiddenRequestError + ); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "Profile is not configured for EST enrollment" + ); + }); + + it("should throw NotFoundError when EST configuration is missing", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: undefined // Missing EST config + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "EST configuration not found for this profile" + ); + }); + }); +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts new file mode 100644 index 000000000..c43dee889 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -0,0 +1,824 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +const validateAndEncryptPemCaChain = async ( + caChain: string, + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates || certificates.length === 0) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + return { encryptedCaChain: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` }); + } +}; + +const decryptCaChain = async ( + encryptedCaChain: Buffer, + projectId: string, + kmsService: Pick, + projectDAL: Pick +): Promise => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: encryptedCaChain + }); + + return decryptedCaChain.toString(); + } catch (error) { + throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` }); + } +}; + +export type TCertificateProfileCreateData = Omit & { + estConfig?: TEstConfigData; + apiConfig?: TApiConfigData; +}; + +type TCertificateProfileServiceFactoryDep = { + certificateProfileDAL: TCertificateProfileDALFactory; + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; + estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + permissionService: Pick; + kmsService: Pick; + projectDAL: Pick; +}; + +export type TCertificateProfileServiceFactory = ReturnType; + +const convertDalToService = (dalResult: Record): TCertificateProfile => { + return { + ...dalResult, + enrollmentType: dalResult.enrollmentType as EnrollmentType + } as TCertificateProfile; +}; + +export const certificateProfileServiceFactory = ({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL +}: TCertificateProfileServiceFactoryDep) => { + const createProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionSub.CertificateProfiles + ); + + // Validate that certificate template exists and belongs to the same project + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + // Check for slug uniqueness within project + const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId); + if (existingSlugProfile) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + + // Validate enrollment configuration requirements + if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { + throw new ForbiddenRequestError({ + message: "EST enrollment requires EST configuration" + }); + } + if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { + throw new ForbiddenRequestError({ + message: "API enrollment requires API configuration" + }); + } + + // Create enrollment configs and profile + const profile = await certificateProfileDAL.transaction(async (tx) => { + let estConfigId: string | null = null; + let apiConfigId: string | null = null; + + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { + const appCfg = getConfig(); + // Hash the passphrase + const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS); + + let encryptedCaChainBuffer: Buffer | null = null; + if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + data.estConfig.caChain, + projectId, + kmsService, + projectDAL + ); + encryptedCaChainBuffer = encryptedCaChain; + } + + const estConfig = await estEnrollmentConfigDAL.create( + { + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, + hashedPassphrase, + encryptedCaChain: encryptedCaChainBuffer + }, + tx + ); + estConfigId = estConfig.id; + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { + const apiConfig = await apiEnrollmentConfigDAL.create( + { + autoRenew: data.apiConfig.autoRenew, + autoRenewDays: data.apiConfig.autoRenewDays + }, + tx + ); + apiConfigId = apiConfig.id; + } + + // Create the profile with the created config IDs + const { estConfig, apiConfig, ...profileData } = data; + const profileResult = await certificateProfileDAL.create( + { + ...profileData, + projectId, + estConfigId, + apiConfigId + }, + tx + ); + + return profileResult; + }); + + return convertDalToService(profile); + }; + + const updateProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + data: TCertificateProfileUpdate; + }): Promise => { + const existingProfile = await certificateProfileDAL.findById(profileId); + if (!existingProfile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingProfile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionSub.CertificateProfiles + ); + + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== existingProfile.projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + if (data.slug && data.slug !== existingProfile.slug) { + const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId( + data.slug, + existingProfile.projectId + ); + if (conflictingProfile && conflictingProfile.id !== profileId) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + } + + const { estConfig, apiConfig, ...profileUpdateData } = data; + + const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { + if (estConfig && existingProfile.estConfigId) { + const updateData: { + disableBootstrapCaValidation: boolean; + hashedPassphrase?: string; + encryptedCaChain?: Buffer; + } = { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false + }; + + if (estConfig.passphrase) { + updateData.hashedPassphrase = await crypto + .hashing() + .createHash(estConfig.passphrase, getConfig().SALT_ROUNDS); + } + + if (estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + estConfig.caChain, + existingProfile.projectId, + kmsService, + projectDAL + ); + updateData.encryptedCaChain = encryptedCaChain; + } + + await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx); + } + + if (apiConfig && existingProfile.apiConfigId) { + await apiEnrollmentConfigDAL.updateById( + existingProfile.apiConfigId, + { + autoRenew: apiConfig.autoRenew, + autoRenewDays: apiConfig.autoRenewDays + }, + tx + ); + } + + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); + return profileResult; + }); + + return convertDalToService(updatedProfile); + }; + + const getProfileById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const converted = convertDalToService(profile); + + if (includeMetrics) { + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return { + ...converted, + metrics + }; + } + + return converted; + }; + + const getProfileByIdWithConfigs = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.estConfig && profile.estConfig.caChain) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + profile.projectId, + kmsService, + projectDAL + ); + profile.estConfig.caChain = decryptedCaChain; + } else { + profile.estConfig.caChain = ""; + } + } catch (error) { + profile.estConfig.caChain = ""; + } + } + + return { + ...profile, + enrollmentType: profile.enrollmentType as EnrollmentType + }; + }; + + const getProfileBySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + return convertDalToService(profile); + }; + + const listProfiles = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise<{ + profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profiles = await certificateProfileDAL.findByProjectId(projectId, { + offset, + limit, + search, + enrollmentType, + caId, + includeMetrics, + expiringDays + }); + + const totalCount = await certificateProfileDAL.countByProjectId(projectId, { + search, + enrollmentType, + caId + }); + + const convertedProfiles = await Promise.all( + profiles.map(async (profile) => { + const profileWithConfigs = profile as TCertificateProfileWithConfigs; + + let decryptedEstConfig = profileWithConfigs.estConfig; + if (decryptedEstConfig && profileWithConfigs.estConfigId) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + projectId, + kmsService, + projectDAL + ); + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: decryptedCaChain + }; + } else if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } catch (error) { + if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } + } + + const converted = convertDalToService(profileWithConfigs); + let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + ...converted, + estConfig: decryptedEstConfig, + apiConfig: profileWithConfigs.apiConfig + }; + + if (includeMetrics) { + const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; + result = { + ...result, + metrics: { + profileId: converted.id, + totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), + activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), + expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), + expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + } + }; + } + + return result; + }) + ); + + return { + profiles: convertedProfiles, + totalCount + }; + }; + + const deleteProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionSub.CertificateProfiles + ); + + const deletedProfile = await certificateProfileDAL.deleteById(profileId); + if (!deletedProfile) { + throw new NotFoundError({ message: "Failed to delete certificate profile" }); + } + return convertDalToService(deletedProfile); + }; + + const getProfileCertificates = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + offset = 0, + limit = 20, + status, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { + offset, + limit, + status, + search + }); + + return certificates; + }; + + const getProfileMetrics = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + expiringDays?: number; + }): Promise => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return metrics; + }; + + const getEstConfigurationByProfile = async ( + params: + | { + profileId: string; + isInternal: true; + } + | { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + profileId: string; + isInternal?: false; + } + ) => { + const { profileId, isInternal = false } = params; + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (!isInternal) { + const { actor, actorId, actorAuthMethod, actorOrgId } = params as { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for EST enrollment" + }); + } + + if (!profile.estConfig) { + throw new NotFoundError({ message: "EST configuration not found for this profile" }); + } + + return { + orgId: profile.projectId, + isEnabled: true, + caChain: profile.estConfig.caChain, + disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, + hashedPassphrase: profile.estConfig.passphrase + }; + }; + + return { + createProfile, + updateProfile, + getProfileById, + getProfileByIdWithConfigs, + getProfileBySlug, + listProfiles, + deleteProfile, + getProfileCertificates, + getProfileMetrics, + getEstConfigurationByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts new file mode 100644 index 000000000..a6d53a0f3 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -0,0 +1,86 @@ +import { + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate +} from "@app/db/schemas/pki-certificate-profiles"; + +export enum EnrollmentType { + API = "api", + EST = "est" +} + +export type TCertificateProfile = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileInsert = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileUpdate = Omit & { + enrollmentType?: EnrollmentType; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TCertificateProfileWithConfigs = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; + metrics?: TCertificateProfileMetrics; +}; + +export interface TCertificateProfileMetrics { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +} + +export interface TCertificateProfileCertificate { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: Date; + notAfter: Date; + revokedAt: Date | null; + createdAt: Date; +} + +export type TCertificateProfileWithRawMetrics = TCertificateProfile & { + total_certificates?: string; + active_certificates?: string; + expired_certificates?: string; + expiring_certificates?: string; + revoked_certificates?: string; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts new file mode 100644 index 000000000..3b935f26a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -0,0 +1,244 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update +} from "./certificate-template-v2-types"; + +export type TCertificateTemplateV2DALFactory = ReturnType; + +interface CountResult { + count: string; +} + +export const certificateTemplateV2DALFactory = (db: TDbClient) => { + const certificateTemplateV2Orm = ormify(db, TableName.PkiCertificateTemplateV2); + + const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { + const serialized = { ...data } as Record; + + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + + jsonFields.forEach((field) => { + const value = serialized[field]; + if (value !== undefined && typeof value !== "string") { + serialized[field] = JSON.stringify(value); + } + }); + + return serialized; + }; + + const parseJsonFields = (raw: Record): TCertificateTemplateV2 => { + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + const parsed = { ...raw } as Record; + + jsonFields.forEach((field) => { + const value = raw[field]; + if (value !== null && value !== undefined) { + if (typeof value === "string") { + try { + parsed[field] = JSON.parse(value); + } catch (error) { + throw new Error( + `Invalid JSON in field '${field}': ${error instanceof Error ? error.message : "Parse error"}` + ); + } + } else { + parsed[field] = value; + } + } else { + parsed[field] = undefined; + } + }); + + return parsed as TCertificateTemplateV2; + }; + + const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .insert(serializedData as TPkiCertificateTemplatesV2Insert) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + throw new Error("Failed to create certificate template v2"); + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate template v2" }); + } + }; + + const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .update(serializedData) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate template v2" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .del() + .returning("*")) as Record[]; + + return certificateTemplateV2; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate template v2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2).where({ id }).first()) as + | Record + | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { offset = 0, limit = 20, search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit); + + return certificateTemplatesV2.map((template: Record) => parseJsonFields(template)); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ name, projectId }) + .first()) as Record | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); + } + }; + + const isTemplateInUse = async (templateId: string, tx?: Knex) => { + try { + const profileCount = await (tx || db)(TableName.PkiCertificateProfile) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0; + + const certCount = await (tx || db)(TableName.Certificate) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0; + + return profileUsage || certUsage; + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" }); + } + }; + + const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => { + try { + const profiles = await (tx || db)(TableName.PkiCertificateProfile) + .select("id", "slug", "description") + .where({ certificateTemplateId: templateId }); + + return profiles as Array<{ id: string; slug: string; description?: string }>; + } catch (error) { + throw new DatabaseError({ error, name: "Get profiles using certificate template v2" }); + } + }; + + return { + ...certificateTemplateV2Orm, + create, + updateById, + deleteById, + findById, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + isTemplateInUse, + getProfilesUsingTemplate + }; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts new file mode 100644 index 000000000..9fcc51a57 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -0,0 +1,181 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(new RE2("^\\d+[dhmy]$"), { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + }) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one signature algorithm must be provided") + .optional(), + keyAlgorithm: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one key algorithm must be provided") + .optional() +}); + +export const certificateTemplateV2ResponseSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid("Project ID must be valid"), + name: z + .string() + .trim() + .min(1, "Template name is required") + .max(255, "Template name must be less than 255 characters") + .regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"), + description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const certificateRequestSchema = z.object({ + commonName: z + .string() + .trim() + .min(1, "Common name cannot be empty") + .max(64, "Common name must be less than 64 characters") + .optional(), + organization: z + .string() + .trim() + .min(1, "Organization cannot be empty") + .max(64, "Organization must be less than 64 characters") + .optional(), + country: z + .string() + .trim() + .min(2, "Country code must be 2 characters") + .max(2, "Country code must be 2 characters") + .optional(), + keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(), + extendedKeyUsages: z + .array(z.nativeEnum(CertExtendedKeyUsageType)) + .min(1, "At least one extended key usage must be provided") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: sanTypeSchema, + 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 SAN must be provided") + .optional(), + validity: z + .object({ + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'") + }) + .optional(), + signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(), + keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional() +}); + +export const validateCertificateRequestSchema = z.object({ + templateId: z.string().uuid(), + request: certificateRequestSchema +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts new file mode 100644 index 000000000..daacc5dde --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -0,0 +1,1745 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + certificateTemplateV2ServiceFactory, + TCertificateTemplateV2ServiceFactory +} from "./certificate-template-v2-service"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TTemplateV2Policy +} from "./certificate-template-v2-types"; + +enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +describe("CertificateTemplateV2Service", () => { + let service: TCertificateTemplateV2ServiceFactory; + + const mockCertificateTemplateV2DAL = { + findBySlugAndProjectId: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + getProfilesUsingTemplate: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + insertMany: vi.fn(), + batchInsert: vi.fn(), + upsert: vi.fn(), + countDocuments: vi.fn() + } as any; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const samplePolicy: TTemplateV2Policy = { + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com", "example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example Inc", "Example Corp"], + denied: ["Malicious Corp"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com", "*.api.example.com"], + required: ["api.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["admin@example.com"], + denied: ["blocked@example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [CertKeyUsageType.DATA_ENCIPHERMENT] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + max: "90d" + }, + algorithms: { + signature: ["SHA256-RSA", "SHA256-ECDSA"], + keyAlgorithm: ["RSA-2048", "RSA-4096", "ECDSA-P256"] + } + }; + + const sampleTemplate: TCertificateTemplateV2 = { + id: "template-123", + projectId: "project-123", + name: "web-server-template", + description: "Template for web server certificates", + ...samplePolicy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const mockPermission = { + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn().mockReturnValue(null), + rulesFor: vi.fn().mockReturnValue([]), + rules: [], + detectSubjectType: vi.fn().mockReturnValue("certificate-templates-v2"), + modelName: "certificate-templates-v2", + throwUnlessCan: vi.fn(), + unlessCan: vi.fn().mockReturnValue({ throwUnlessCan: vi.fn() }) + }; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: mockPermission + }) + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + mockPermissionService.getProjectPermission.mockResolvedValue({ + permission: mockPermission + }); + + mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null); + mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null); + + service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createTemplateV2", () => { + const createData: Omit = { + name: "test-template", + description: "Test description", + ...samplePolicy + }; + + it("should create template with valid policy", async () => { + mockCertificateTemplateV2DAL.create.mockResolvedValue(sampleTemplate); + + const result = await service.createTemplateV2({ + ...mockActor, + projectId: "project-123", + data: createData + }); + + expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({ + ...createData, + projectId: "project-123", + name: expect.any(String) + }); + expect(result).toEqual(sampleTemplate); + }); + + // Previously tested service-level validations that are now schema-level: + // - Missing attributes validation (now mandatory in schema) + // - Missing key usages validation (now mandatory in schema) + // - Default signature algorithm not in allowed list (now schema-level validation) + // - Default key algorithm not in allowed list (now schema-level validation) + }); + + describe("updateTemplateV2", () => { + it("should update template with valid data", async () => { + const updateData = { name: "updated-template-name" }; + const updatedTemplate = { ...sampleTemplate, ...updateData }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.updateById.mockResolvedValue(updatedTemplate); + + const result = await service.updateTemplateV2({ + ...mockActor, + templateId: "template-123", + data: updateData + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", { + ...updateData, + name: expect.any(String) + }); + expect(result).toEqual(updatedTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.updateTemplateV2({ + ...mockActor, + templateId: "nonexistent-template", + data: { name: "updated-name" } + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getTemplateV2ById", () => { + it("should return template when found", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + + const result = await service.getTemplateV2ById({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.getTemplateV2ById({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listTemplatesV2", () => { + it("should return templates list with pagination", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + const result = await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + offset: 0, + limit: 20 + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: undefined + }); + expect(result).toEqual({ templates, totalCount }); + }); + + it("should handle search parameter", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + search: "web server" + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: "web server" + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: "web server" + }); + }); + }); + + describe("deleteTemplateV2", () => { + it("should delete template when not in use", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(false); + mockCertificateTemplateV2DAL.deleteById.mockResolvedValue(sampleTemplate); + + const result = await service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.isTemplateInUse).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template is in use", async () => { + const mockProfiles = [ + { id: "profile-1", slug: "web-server-profile", description: "Web server certificate profile" }, + { id: "profile-2", slug: "api-gateway-profile", description: "API gateway certificate profile" } + ]; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(true); + mockCertificateTemplateV2DAL.getProfilesUsingTemplate.mockResolvedValue(mockProfiles); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }) + ).rejects.toThrow(ForbiddenRequestError); + + expect(mockCertificateTemplateV2DAL.getProfilesUsingTemplate).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled(); + }); + }); + + describe("validateCertificateRequest", () => { + const validRequest: TCertificateRequest = { + commonName: "api.example.com", + organization: "Example Inc", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + beforeEach(() => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + }); + + it("should validate valid certificate request", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect(service.validateCertificateRequest("nonexistent-template", validRequest)).rejects.toThrow( + NotFoundError + ); + }); + + it("should validate allowed attribute values against pattern", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should detect attribute values that don't match allowed patterns", async () => { + const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'forbidden.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should detect denied attribute values", async () => { + const templateWithDeny = { + ...sampleTemplate, + subject: [ + ...sampleTemplate.subject!, + { + type: CertSubjectAttributeType.ORGANIZATION, + denied: ["Forbidden Corp"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDeny); + + const invalidRequest = { ...validRequest, organization: "Forbidden Corp" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("organization value 'Forbidden Corp' is denied by template policy"); + }); + + it("should detect missing required key usages", async () => { + const invalidRequest = { ...validRequest, keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required key usages: key_encipherment"); + }); + + it("should detect invalid key usages", async () => { + const invalidRequest = { + ...validRequest, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, "invalid_usage"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: invalid_usage"); + }); + + it("should detect missing required extended key usages", async () => { + const invalidRequest = { ...validRequest, extendedKeyUsages: [] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required extended key usages: server_auth"); + }); + + it("should detect invalid extended key usages", async () => { + const invalidRequest = { + ...validRequest, + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, "invalid_eku"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid extended key usages: invalid_eku"); + }); + + it("should detect missing required SAN entries", async () => { + const invalidRequest = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Required email SAN matching pattern 'admin@example.com' not found in request"); + }); + + it("should validate SAN values against allowed patterns", async () => { + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "dns_name SAN value 'forbidden.com' does not match allowed patterns: *.example.com, *.api.example.com" + ); + }); + + it("should detect denied SAN values", async () => { + const templateWithDenySan = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["forbidden@example.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDenySan); + + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "forbidden@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'forbidden@example.com' found in request"); + }); + + it("should detect invalid signature algorithm", async () => { + const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Signature algorithm 'MD5-RSA' is not allowed by template policy"); + }); + + it("should detect invalid key algorithm", async () => { + const invalidRequest = { ...validRequest, keyAlgorithm: "RSA-1024" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key algorithm 'RSA-1024' is not allowed by template policy"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const invalidRequest = { ...validRequest, validity: { ttl: "180d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const templateWithMaxDuration = { + ...sampleTemplate, + validity: { + max: "90d" + } + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMaxDuration); + + const invalidRequest = { ...validRequest, validity: { ttl: "100d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should handle various TTL formats", async () => { + const testCases = [ + { ttl: "24h", shouldBeValid: true }, + { ttl: "30d", shouldBeValid: true }, + { ttl: "90d", shouldBeValid: true }, + { ttl: "3m", shouldBeValid: true }, + { ttl: "1y", shouldBeValid: false }, + { ttl: "invalid", shouldThrow: true } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { ...validRequest, validity: { ttl: testCase.ttl } }; + + if (testCase.shouldThrow) { + await expect(service.validateCertificateRequest("template-123", request)).rejects.toThrow( + `Invalid TTL format: ${testCase.ttl}` + ); + } else { + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }) + ); + }); + + it("should allow optional key usages and extended key usages", async () => { + const requestWithOptionalUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithOptionalUsages); + + expect(result.isValid).toBe(true); + }); + + it("should handle camelCase key usage mapping correctly", async () => { + const templateWithOptionalUsages = { + ...sampleTemplate, + keyUsages: { + requiredUsages: { + all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] + }, + optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } + }, + extendedKeyUsages: { + requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } + } + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); + + const requestWithCamelCaseUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should validate wildcard patterns in allow attributes", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithWildcard = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithWildcard); + expect(result.isValid).toBe(true); + }); + + it("should reject wildcard patterns that don't match", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithNonMatchingWildcard = { + commonName: "api.notexample.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should require attribute value when allow rule exists", async () => { + const emptyAllowTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyAllowTemplate); + + const requestWithoutCommonName = { + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName); + expect(result.isValid).toBe(true); + }); + + it("should prevent certificates from including denied SANs", async () => { + const denyTemplate = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibitedSan = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + }); + + describe("comprehensive template validation scenarios", () => { + it("should handle template with minimal required fields only", async () => { + const minimalTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [], + validity: { + max: "30d" + }, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate); + + const minimalReq = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + validity: { ttl: "15d" } + }; + + const result = await service.validateCertificateRequest("template-123", minimalReq); + expect(result.isValid).toBe(true); + }); + + it("should handle template with all fields set to allow", async () => { + const allowTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["*"] + } + ], + keyUsages: { + allowed: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(allowTemplate); + + const emptyRequest = { + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", emptyRequest); + expect(result.isValid).toBe(true); + }); + + it("should handle template with SAN fields denied", async () => { + const denyTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*"] + }, + { + type: CertSubjectAlternativeNameType.URI, + denied: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibited = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }, + { type: CertSubjectAlternativeNameType.URI as const, value: "https://example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibited); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + expect(result.errors).toContain("uri SAN matching denied pattern 'https://example.com' found in request"); + }); + + it("should validate complex attribute value constraints", async () => { + const constrainedTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com", "test.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(constrainedTemplate); + + const validConstrainedRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validConstrainedRequest); + expect(validResult.isValid).toBe(true); + + const invalidConstrainedRequest = { + commonName: "forbidden.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest); + expect(invalidResult.isValid).toBe(false); + expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); + }); + + it("should validate SAN value constraints with multiple types", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["192.168.1.*"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.100" }, + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validSanRequest); + expect(validResult.isValid).toBe(true); + + const missingSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + validity: { ttl: "30d" } + }; + + const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest); + expect(missingResult.isValid).toBe(false); + expect(missingResult.errors).toContain( + "Required email SAN matching pattern '*@example.com' not found in request" + ); + }); + + it("should validate key usage combinations thoroughly", async () => { + const keyUsageTemplate = { + ...sampleTemplate, + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(keyUsageTemplate); + + const minimalUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const minimalResult = await service.validateCertificateRequest("template-123", minimalUsageRequest); + expect(minimalResult.isValid).toBe(true); + + const extendedUsageRequest = { + commonName: "example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH], + validity: { ttl: "30d" } + }; + + const extendedResult = await service.validateCertificateRequest("template-123", extendedUsageRequest); + expect(extendedResult.isValid).toBe(true); + + const forbiddenUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, CertKeyUsageType.CRL_SIGN], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const forbiddenResult = await service.validateCertificateRequest("template-123", forbiddenUsageRequest); + expect(forbiddenResult.isValid).toBe(false); + expect(forbiddenResult.errors).toContain("Invalid key usages: crl_sign"); + }); + + it("should validate algorithm constraints thoroughly", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512"], + keyAlgorithm: ["RSA-2048", "RSA-4096"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const validAlgoRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validAlgoRequest); + expect(validResult.isValid).toBe(true); + + const invalidSigRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "RSA_2048", + validity: { ttl: "30d" } + }; + + const invalidSigResult = await service.validateCertificateRequest("template-123", invalidSigRequest); + expect(invalidSigResult.isValid).toBe(false); + expect(invalidSigResult.errors).toContain( + "Signature algorithm 'ECDSA-SHA256' is not allowed by template policy" + ); + + const invalidKeyRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "EC_prime256v1", + validity: { ttl: "30d" } + }; + + const invalidKeyResult = await service.validateCertificateRequest("template-123", invalidKeyRequest); + expect(invalidKeyResult.isValid).toBe(false); + expect(invalidKeyResult.errors).toContain("Key algorithm 'EC_prime256v1' is not allowed by template policy"); + }); + + it("should validate validity period edge cases", async () => { + const validityTemplate = { + ...sampleTemplate, + validity: { + max: "365d" + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(validityTemplate); + + const testCases = [ + { ttl: "1d", shouldBeValid: true, description: "minimum duration" }, + { ttl: "365d", shouldBeValid: true, description: "maximum duration" }, + { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" }, + { ttl: "23h", shouldBeValid: true, description: "valid duration under max" }, + { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" }, + { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" }, + { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" }, + { ttl: "1y", shouldBeValid: true, description: "exactly 365 days in years" } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: testCase.ttl } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + } + }) + ); + }); + }); + + describe("unlisted field validation", () => { + it("should reject requests with unlisted subject attributes", async () => { + const templateWithLimitedAttributes = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedAttributes); + + const requestWithUnlistedKeyUsage = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.ENCIPHER_ONLY], // ENCIPHER_ONLY not allowed + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedKeyUsage); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: encipher_only"); + }); + + it("should reject requests with unlisted SAN types", async () => { + const templateWithLimitedSans = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedSans); + + const requestWithUnlistedSan = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" } // This should be rejected + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN is not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with unlisted key usages when template doesn't define any", async () => { + const templateWithoutKeyUsages = { + ...sampleTemplate, + keyUsages: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutKeyUsages); + + const requestWithKeyUsages = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithKeyUsages); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key usages are not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with algorithms when template doesn't define any", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", // This should be rejected + keyAlgorithm: "RSA-2048", // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA-2048' is not allowed by template policy (not defined in template)" + ); + }); + }); + + describe("comprehensive subject attribute validation", () => { + it("should validate all subject attribute types", async () => { + const comprehensiveTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + required: ["*"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(comprehensiveTemplate); + + const validComprehensiveRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validComprehensiveRequest); + expect(validResult.isValid).toBe(true); + + // Test missing mandatory field + const missingCommonNameRequest = { + ...validComprehensiveRequest, + commonName: undefined + }; + + const missingCommonNameResult = await service.validateCertificateRequest( + "template-123", + missingCommonNameRequest + ); + expect(missingCommonNameResult.isValid).toBe(false); + expect(missingCommonNameResult.errors).toContain("Missing required common_name attribute"); + }); + }); + + describe("improved wildcard pattern validation", () => { + it("should handle complex wildcard patterns", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["v1.api.example.com", "service-auth.internal.com", "exact-match.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + // Valid patterns + { commonName: "v1.api.example.com", shouldBeValid: true }, + { commonName: "service-auth.internal.com", shouldBeValid: true }, + { commonName: "exact-match.com", shouldBeValid: true }, + // Invalid patterns + { commonName: "api.example.com", shouldBeValid: false }, // Missing subdomain for *.api.example.com + { commonName: "service.internal.com", shouldBeValid: false }, // Missing dash and wildcard part + { commonName: "not-exact-match.com", shouldBeValid: false } + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect( + result.errors.some( + (error) => error.includes("does not match allowed patterns") || error.includes("not in allowed values") + ) + ).toBe(true); + } + } + }); + + it("should handle special regex characters in wildcard patterns", async () => { + const specialCharTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(specialCharTemplate); + + const testCases = [ + { commonName: "app.test-site.com", shouldBeValid: true }, + { commonName: "service[1-9].example.com", shouldBeValid: true }, // Should match exactly, not as regex + { commonName: "service1.example.com", shouldBeValid: false }, // Should not match as regex pattern + { commonName: "api.{prod,staging}.com", shouldBeValid: true }, // Should match exactly + { commonName: "api.prod.com", shouldBeValid: false } // Should not match as regex pattern + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }); + }); + + describe("algorithm validation", () => { + it("should validate signature algorithm constraints", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], + keyAlgorithm: ["RSA_2048", "RSA_4096", "EC_prime256v1"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const testCases = [ + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048", + shouldBeValid: true, + description: "allowed algorithms" + }, + { + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + shouldBeValid: true, + description: "different allowed algorithms" + }, + { + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "EC_prime256v1", + shouldBeValid: true, + description: "ECDSA algorithms" + }, + { + signatureAlgorithm: "MD5-RSA", + keyAlgorithm: "RSA_2048", + shouldBeValid: false, + description: "disallowed signature algorithm" + }, + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_1024", + shouldBeValid: false, + description: "disallowed key algorithm" + }, + { + signatureAlgorithm: undefined, + keyAlgorithm: undefined, + shouldBeValid: true, + description: "no algorithms specified (should use defaults)" + } + ]; + + for (const testCase of testCases) { + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" } + ], + signatureAlgorithm: testCase.signatureAlgorithm, + keyAlgorithm: testCase.keyAlgorithm + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((error) => error.includes("algorithm") || error.includes("Algorithm"))).toBe( + true + ); + } + } + }); + + it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined, + sans: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject algorithms when template has no algorithm constraints", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA_2048' is not allowed by template policy (not defined in template)" + ); + }); + + it("should allow requests that match any of multiple attribute policies of same type", async () => { + const multipleAttributePoliciesTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multipleAttributePoliciesTemplate); + + // Test case that matches first policy + const requestMatchingFirstPolicy = { + commonName: "test.infisical.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", requestMatchingFirstPolicy); + expect(result1.isValid).toBe(true); + + // Test case that matches second policy + const requestMatchingSecondPolicy = { + commonName: "test.infisical2.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical2.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result2 = await service.validateCertificateRequest("template-123", requestMatchingSecondPolicy); + expect(result2.isValid).toBe(true); + + // Test case that matches neither policy + const requestMatchingNeitherPolicy = { + commonName: "test.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result3 = await service.validateCertificateRequest("template-123", requestMatchingNeitherPolicy); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "common_name value 'test.example.com' does not match allowed patterns: *.infisical.com, *.infisical2.com" + ); + }); + }); + + describe("New validation logic with allow/deny/require", () => { + it("should validate complex attribute value constraints", async () => { + const complexTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example*"] + }, + { + type: CertSubjectAttributeType.COUNTRY, + denied: ["XX"] + } + ], + sans: [] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(complexTemplate); + + const validComplexRequest = { + commonName: "api.example.com", + organization: "Example Corp", + country: "US", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validComplexRequest); + expect(result1.isValid).toBe(true); + + const invalidCountryRequest = { ...validComplexRequest, country: "XX" }; + const result2 = await service.validateCertificateRequest("template-123", invalidCountryRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("country value 'XX' is denied by template policy"); + const invalidOrgRequest = { ...validComplexRequest, organization: "Different Corp" }; + const result3 = await service.validateCertificateRequest("template-123", invalidOrgRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "organization value 'Different Corp' does not match allowed patterns: Example*" + ); + }); + + it("should handle SAN allow/deny/require logic", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + denied: ["192.168.1.*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validSanRequest); + expect(result1.isValid).toBe(true); + + const missingEmailRequest = { + ...validSanRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result2 = await service.validateCertificateRequest("template-123", missingEmailRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("Required email SAN matching pattern '*@example.com' not found in request"); + + const deniedIpRequest = { + ...validSanRequest, + subjectAlternativeNames: [ + ...validSanRequest.subjectAlternativeNames, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.100" } + ] + }; + + const result3 = await service.validateCertificateRequest("template-123", deniedIpRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain("ip_address SAN matching denied pattern '192.168.1.100' found in request"); + }); + + it("should validate wildcard patterns correctly", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.acme.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.api.acme.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + { cn: "api.acme.com", san: "v1.api.acme.com", shouldPass: true }, + { cn: "www.acme.com", san: "beta.api.acme.com", shouldPass: true }, + { cn: "acme.com", san: "api.acme.com", shouldPass: false }, // Missing subdomain + { cn: "api.notacme.com", san: "v1.api.acme.com", shouldPass: false }, // Wrong domain + { cn: "api.acme.com", san: "api.acme.com", shouldPass: false } // SAN missing required subdomain + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.cn, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: testCase.san }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldPass); + } + }); + + it("should enforce multiple required SAN types", async () => { + const multiRequiredTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.URI, + required: ["https://*.example.com/*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multiRequiredTemplate); + + const completeRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" }, + { type: CertSubjectAlternativeNameType.URI, value: "https://api.example.com/webhook" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", completeRequest); + expect(result1.isValid).toBe(true); + + const incompleteRequest = { + ...completeRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result2 = await service.validateCertificateRequest("template-123", incompleteRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain( + "Required uri SAN matching pattern 'https://*.example.com/*' not found in request" + ); + }); + }); + }); +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts new file mode 100644 index 000000000..c1942b793 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -0,0 +1,959 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CertSubjectAttributeType } from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update, + TTemplateValidationResult +} from "./certificate-template-v2-types"; + +type TCertificateTemplateV2ServiceFactoryDep = { + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + permissionService: Pick; +}; + +export const certificateTemplateV2ServiceFactory = ({ + certificateTemplateV2DAL, + permissionService +}: TCertificateTemplateV2ServiceFactoryDep) => { + const consolidateAttributeArray = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + attributes: T[] + ): T[] => { + const consolidated = new Map(); + + attributes.forEach((attr) => { + const existing = consolidated.get(attr.type); + if (existing) { + throw new ForbiddenRequestError({ + message: `Duplicate attribute type '${attr.type}' found in request. Each attribute type must appear only once.` + }); + } else { + consolidated.set(attr.type, attr); + } + }); + + return Array.from(consolidated.values()); + }; + + const parseTTL = (ttl: string): number => { + const regex = new RE2("^(\\d+)([dmyh])$"); + const match = regex.exec(ttl); + if (!match) { + throw new Error(`Invalid TTL format: ${ttl}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + case "m": + return value * 30 * 24 * 60 * 60 * 1000; + case "y": + return value * 365 * 24 * 60 * 60 * 1000; + default: + throw new Error(`Unsupported TTL unit: ${unit}`); + } + }; + + const validateSubjectAttributePolicy = ( + subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!subject || subject.length === 0) return; + + // Validate each subject attribute policy + for (const attr of subject) { + // Ensure at least one field is provided + if (!attr.allowed && !attr.required && !attr.denied) { + throw new ForbiddenRequestError({ + message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value` + }); + } + + // Check for duplicate values within arrays + const arrays = [ + { name: "allowed", values: attr.allowed }, + { name: "required", values: attr.required }, + { name: "denied", values: attr.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'` + }); + } + } + } + } + }; + + const validateSanPolicy = ( + sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!sans || sans.length === 0) return; + + // Validate each SAN policy + for (const san of sans) { + if (!san.allowed && !san.required && !san.denied) { + throw new ForbiddenRequestError({ + message: `SAN type '${san.type}' must have at least one allowed, required, or denied value` + }); + } + + const arrays = [ + { name: "allowed", values: san.allowed }, + { name: "required", values: san.required }, + { name: "denied", values: san.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for SAN type '${san.type}'` + }); + } + } + } + } + }; + + const generateTemplateSlug = (baseName?: string): string => { + if (baseName) { + return slugify(baseName); + } + return slugify(alphaNumericNanoId(12)); + }; + + const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId); + if (!existingTemplate || (templateId && existingTemplate.id === templateId)) { + return desiredSlug; + } + const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; + const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId); + if (!existingAlternative) { + return alternativeSlug; + } + + const randomSlug = slugify(alphaNumericNanoId(12)); + return randomSlug; + }; + + const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); + }; + + const createWildcardRegex = (pattern: string): RegExp => { + const wildcardRegex = new RE2(/\*/g); + const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__"); + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = withPlaceholder.replace(escapeRegex, "\\$&"); + const placeholderRegex = new RE2(/__WILDCARD__/g); + const regexPattern = escaped.replace(placeholderRegex, ".*"); + return new RE2(`^${regexPattern}$`); + }; + + const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + return mapping[templateFormat] || templateFormat; + }; + + const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "RSA-2048": "RSA_2048", + "RSA-3072": "RSA_3072", + "RSA-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1", + "ECDSA-P521": "EC_secp521r1" + }; + return mapping[templateFormat] || templateFormat; + }; + + const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => { + if (!keyUsages) return; + + if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: keyUsages.allowed }, + { name: "required", values: keyUsages.required }, + { name: "denied", values: keyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} key usages list` + }); + } + } + } + }; + + const validateExtendedKeyUsagePolicy = (extendedKeyUsages: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }) => { + if (!extendedKeyUsages) return; + + if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Extended key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: extendedKeyUsages.allowed }, + { name: "required", values: extendedKeyUsages.required }, + { name: "denied", values: extendedKeyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} extended key usages list` + }); + } + } + } + }; + + const validateValueAgainstConstraints = ( + value: string, + allowedValues: string[], + fieldName: string + ): { isValid: boolean; error?: string } => { + if (!allowedValues || allowedValues.length === 0) { + return { isValid: true }; + } + + const hasWildcards = allowedValues.some(isWildcardPattern); + + for (const allowedValue of allowedValues) { + if (isWildcardPattern(allowedValue)) { + try { + const regex = createWildcardRegex(allowedValue); + if (regex.test(value)) { + return { isValid: true }; + } + } catch (error) { + if (allowedValue === value) { + return { isValid: true }; + } + } + } else if (allowedValue === value) { + return { isValid: true }; + } + } + + if (hasWildcards) { + return { + isValid: false, + error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}` + }; + } + return { + isValid: false, + error: `${fieldName} value '${value}' is not in allowed values list` + }; + }; + + const validateRequestAgainstPolicy = ( + template: TCertificateTemplateV2, + request: TCertificateRequest + ): TTemplateValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate subject attributes + const subjectPolicies = template.subject; + const requestAttributes = new Map(); + if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName); + if (request.organization) { + requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization); + } + if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); + + if (subjectPolicies && subjectPolicies.length > 0) { + for (const attrPolicy of subjectPolicies) { + const requestValue = requestAttributes.get(attrPolicy.type); + + if (attrPolicy.required && attrPolicy.required.length > 0) { + if (!requestValue) { + errors.push(`Missing required ${attrPolicy.type} attribute`); + } else { + // Validate that the request value matches the required pattern + const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + if (!hasMatchingRequired) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}` + ); + } + } + } + + if (requestValue) { + let isValueDenied = false; + if (attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + isValueDenied = true; + } + } + + if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + let satisfiesRequired = false; + if (attrPolicy.required && attrPolicy.required.length > 0) { + satisfiesRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } + } + } + + // Check if any request attributes are not covered by template policies + for (const [attrType] of requestAttributes) { + const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType); + if (!hasPolicy) { + errors.push(`${attrType} is not allowed by template policy (not defined in template)`); + } + } + } else if (requestAttributes.size > 0) { + // No subject policies defined but request has subject attributes - deny all + for (const [attrType] of requestAttributes) { + errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`); + } + } + + // Validate Subject Alternative Names + const sansPolicies = template.sans; + if (sansPolicies && sansPolicies.length > 0) { + const requestSansByType = new Map(); + + // Group request SANs by type + if (request.subjectAlternativeNames) { + for (const san of request.subjectAlternativeNames) { + if (!requestSansByType.has(san.type)) { + requestSansByType.set(san.type, []); + } + requestSansByType.get(san.type)!.push(san.value); + } + } + + // Validate each SAN policy + for (const sanPolicy of sansPolicies) { + const requestSans = requestSansByType.get(sanPolicy.type) || []; + + // Check REQUIRED values - at least one SAN must match each required pattern + if (sanPolicy.required && sanPolicy.required.length > 0) { + for (const requiredValue of sanPolicy.required) { + const hasMatchingRequiredSan = requestSans.some((sanValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + + if (!hasMatchingRequiredSan) { + errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`); + } + } + } + + // Check DENIED values - no SAN should match denied patterns + if (sanPolicy.denied && sanPolicy.denied.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`); + if (validation.isValid) { + errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`); + } + } + } + + // Check ALLOWED values - if present, all SANs must match at least one allowed pattern + if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { + for (const sanValue of requestSans) { + let satisfiesRequired = false; + if (sanPolicy.required && sanPolicy.required.length > 0) { + satisfiesRequired = sanPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + } + } + } + } + + // Check if any request SANs are for types not covered by template policies + for (const [requestSanType] of requestSansByType) { + const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType); + if (!hasPolicy) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + } else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) { + // No SAN policies defined but request has SANs - deny all + for (const san of request.subjectAlternativeNames) { + errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`); + } + } + + // Validate key usages + const keyUsagePolicy = template.keyUsages; + if (keyUsagePolicy) { + // Check REQUIRED key usages - must have all required usages + if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) { + const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage)); + if (missingRequired.length > 0) { + errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED key usages - must not have any denied usages + if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) { + const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage)); + if (deniedUsages.length > 0) { + errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED key usages - if present, all usages must be in allowed list + if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; + const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); + } + } + } else if (request.keyUsages && request.keyUsages.length > 0) { + errors.push(`Key usages are not allowed by template policy (not defined in template)`); + } + + // Validate extended key usages + const extendedKeyUsagePolicy = template.extendedKeyUsages; + if (extendedKeyUsagePolicy) { + // Check REQUIRED extended key usages - must have all required usages + if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) { + const missingRequired = extendedKeyUsagePolicy.required.filter( + (usage) => !request.extendedKeyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED extended key usages - must not have any denied usages + if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) { + const deniedUsages = request.extendedKeyUsages.filter((usage) => + extendedKeyUsagePolicy?.denied?.includes(usage) + ); + if (deniedUsages.length > 0) { + errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED extended key usages - if present, all usages must be in allowed list + if ( + request.extendedKeyUsages && + extendedKeyUsagePolicy && + extendedKeyUsagePolicy.allowed && + extendedKeyUsagePolicy.allowed.length > 0 + ) { + const allAllowedExtendedUsages = [ + ...(extendedKeyUsagePolicy.required || []), + ...(extendedKeyUsagePolicy.allowed || []) + ]; + const invalidExtendedUsages = request.extendedKeyUsages.filter( + (usage) => !allAllowedExtendedUsages.includes(usage) + ); + if (invalidExtendedUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`); + } + } + } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { + errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); + } + + // Validate algorithms with new structure + if (request.signatureAlgorithm) { + if (template.algorithms?.signature && template.algorithms.signature.length > 0) { + const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi); + if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { + errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.signature) { + errors.push( + `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + if (request.keyAlgorithm) { + if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) { + const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi); + if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { + errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.keyAlgorithm) { + errors.push( + `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + // Validate validity with new structure + if (request.validity?.ttl && (request.notBefore || request.notAfter)) { + errors.push( + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + ); + } + + if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) { + errors.push("notBefore must be earlier than notAfter"); + } + + // Validate TTL against template validity constraints + if (request.validity?.ttl && template.validity) { + const requestDurationMs = parseTTL(request.validity.ttl); + + // Check maximum duration using max field + if (template.validity.max) { + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDurationMs > maxDurationMs) { + errors.push("Requested validity period exceeds maximum allowed duration"); + } + } + } + // Validate explicit date range against max duration + if ((request.notBefore || request.notAfter) && template.validity?.max) { + const notBefore = request.notBefore || new Date(); + const { notAfter } = request; + + if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { + const requestDuration = notAfter.getTime() - notBefore.getTime(); + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDuration > maxDurationMs) { + errors.push( + `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}` + ); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + }; + + const createTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + if (!data) { + throw new Error("Template data is required"); + } + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + // Generate slug from name and ensure it's unique within project + if (!data.name) { + throw new ForbiddenRequestError({ message: "Template name is required" }); + } + + const slug = generateTemplateSlug(data.name); + const uniqueSlug = await ensureUniqueSlug(projectId, slug); + + const template = await certificateTemplateV2DAL.create({ + ...consolidatedData, + name: uniqueSlug, + projectId + }); + + return template; + }; + + const updateTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + data: TCertificateTemplateV2Update; + }): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findById(templateId); + if (!existingTemplate) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + const updateData = { ...consolidatedData }; + if (data.name && typeof data.name === "string") { + const newSlug = generateTemplateSlug(data.name); + if (newSlug !== existingTemplate.name) { + const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId); + updateData.name = uniqueSlug; + } + } + + const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); + if (!updatedTemplate) { + throw new NotFoundError({ message: "Failed to update certificate template" }); + } + return updatedTemplate; + }; + + const getTemplateV2ById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return template; + }; + + const getTemplateV2BySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return template; + }; + + const listTemplatesV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + }): Promise<{ + templates: TCertificateTemplateV2[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const templates = await certificateTemplateV2DAL.findByProjectId(projectId, { + offset, + limit, + search + }); + + const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }); + + return { + templates, + totalCount + }; + }; + + const deleteTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); + if (isInUse) { + const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); + const profileNames = profilesUsingTemplate + .map((profile: { slug?: string; id: string }) => profile.slug || profile.id) + .join(", "); + + throw new ForbiddenRequestError({ + message: + profilesUsingTemplate.length > 0 + ? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.` + : `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` + }); + } + + const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId); + if (!deletedTemplate) { + throw new NotFoundError({ message: "Failed to delete certificate template" }); + } + return deletedTemplate as TCertificateTemplateV2; + }; + + const validateCertificateRequest = async ( + templateId: string, + request: TCertificateRequest + ): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return validateRequestAgainstPolicy(template, request); + }; + + return { + createTemplateV2, + updateTemplateV2, + getTemplateV2ById, + getTemplateV2BySlug, + listTemplatesV2, + deleteTemplateV2, + validateCertificateRequest + }; +}; + +export type TCertificateTemplateV2ServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts new file mode 100644 index 000000000..de2691d9a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -0,0 +1,98 @@ +import { + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert +} from "@app/db/schemas/pki-certificate-templates-v2"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +export interface TTemplateV2Policy { + subject?: Array<{ + type: CertSubjectAttributeType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: CertSubjectAlternativeNameType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: CertKeyUsageType[]; + required?: CertKeyUsageType[]; + denied?: CertKeyUsageType[]; + }; + extendedKeyUsages?: { + allowed?: CertExtendedKeyUsageType[]; + required?: CertExtendedKeyUsageType[]; + denied?: CertExtendedKeyUsageType[]; + }; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; + }; +} + +export type TCertificateTemplateV2 = TPkiCertificateTemplatesV2 & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Insert = TPkiCertificateTemplatesV2Insert & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Update = Partial< + Pick< + TCertificateTemplateV2, + "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity" + > +>; + +export interface TCertificateRequest { + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + subjectAlternativeNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity?: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +export interface TTemplateValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 20c061bf7..20c0ffd88 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -420,7 +420,7 @@ export const certificateTemplateServiceFactory = ({ }; const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => { - const { certificateTemplateId } = dto; + const { certificateTemplateId, isInternal } = dto; const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); if (!certTemplate) { @@ -429,7 +429,7 @@ export const certificateTemplateServiceFactory = ({ }); } - if (!dto.isInternal) { + if (!isInternal) { const { permission } = await permissionService.getProjectPermission({ actor: dto.actor, actorId: dto.actorId, @@ -440,7 +440,7 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Read, subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 60694b598..e33ddb047 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -3,26 +3,40 @@ import z from "zod"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -export const validateTemplateRegexField = z - .string() - .min(1) - .max(100) - .refine( - (val) => - characterValidator([ - CharacterType.AlphaNumeric, - CharacterType.Spaces, // (space) - CharacterType.Asterisk, // * - CharacterType.At, // @ - CharacterType.Hyphen, // - - CharacterType.Period, // . - CharacterType.Backslash // \ - ])(val), - { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - } - ) - // we ensure that the inputted pattern is computationally safe by limiting star height to 1 - .refine((v) => safe(v), { - message: "Unsafe REGEX pattern" - }); +export const createTemplateFieldValidator = (options?: { + minLength?: number; + maxLength?: number; + allowedCharacters?: CharacterType[]; + customMessage?: string; +}) => { + const { + minLength = 1, + maxLength = 100, + allowedCharacters = [ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ], + customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } = options || {}; + + return ( + z + .string() + .min(minLength) + .max(maxLength) + .refine((val) => characterValidator(allowedCharacters)(val), { + message: customMessage + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }) + ); +}; + +export const validateTemplateRegexField = createTemplateFieldValidator(); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts new file mode 100644 index 000000000..95f9a5077 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -0,0 +1,1463 @@ +/* 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 { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +describe("CertificateV3Service", () => { + let service: TCertificateV3ServiceFactory; + + const mockCertificateDAL: Pick = { + findOne: vi.fn(), + updateById: vi.fn() + }; + + const mockCertificateAuthorityDAL: Pick = { + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn() + }; + + const mockCertificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + > = { + validateCertificateRequest: vi.fn(), + getTemplateV2ById: vi.fn() + }; + + const mockInternalCaService: Pick = + { + signCertFromCa: vi.fn(), + issueCertFromCa: vi.fn() + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [] + } + }) + }; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Mock ForbiddenError.from static method + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Ensure the permission service mock is properly set up + (mockPermissionService.getProjectPermission as any).mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [], + detectSubjectType: vi.fn() + } + }); + + service = certificateV3ServiceFactory({ + certificateDAL: mockCertificateDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + internalCaService: mockInternalCaService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); // Ensure static method mocks are properly restored + }); + + describe("issueCertificateFromProfile", () => { + const mockCertificateRequest = { + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should issue certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + revokedBy: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("privateKey"); + expect(result).toHaveProperty("serialNumber", "123456"); + expect(result).toHaveProperty("certificateId", "cert-123"); + }); + + it("should correctly map camelCase key usages to snake_case before validation", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-camel", + description: "Test camelCase profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template for CamelCase", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for camelCase validation", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const camelCaseRequest = { + commonName: "test.example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ], + validity: { ttl: "10d" } + }; + + const mockCertificateResultWithCa = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + await service.issueCertificateFromProfile({ + profileId, + certificateRequest: camelCaseRequest, + ...mockActor + }); + + // Verify that the template validation service was called with mapped snake_case values + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-123", + expect.objectContaining({ + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ] + }) + ); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est", + description: "Test EST profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(undefined); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("signCertificateFromProfile", () => { + const mockCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC..."; + const mockValidity = { ttl: "30d" }; + + it("should sign certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-sign", + description: "Test signing profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockSignResult = { + certificate: "signed-cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + serialNumber: "789012", + commonName: "test.example.com", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-456", + serialNumber: "789012", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Signing Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const mockTemplate = { + id: "template-123", + name: "Test Signing Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for signing certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("serialNumber", "789012"); + expect(result).toHaveProperty("certificateId", "cert-456"); + expect(result).not.toHaveProperty("privateKey"); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-sign", + description: "Test EST signing profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("orderCertificateFromProfile", () => { + const mockCertificateOrder = { + altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + validity: { ttl: "30d" }, + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should create order successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-order", + description: "Test order profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Order Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for ordering certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "example.com", + friendlyName: "Test Order Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), + caCertId: null, + keyUsages: ["DIGITAL_SIGNATURE"], + extendedKeyUsages: ["SERVER_AUTH"], + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }); + + expect(result).toHaveProperty("orderId"); + expect(result).toHaveProperty("status", "valid"); + expect(result).toHaveProperty("certificate"); + expect(result.subjectAlternativeNames).toHaveLength(1); + expect(result.subjectAlternativeNames[0]).toEqual({ + type: ACMESANType.DNS, + value: "example.com", + status: CertificateOrderStatus.VALID + }); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-order", + description: "Test EST order profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("algorithm compatibility (integration tests)", () => { + const mockProfile = { + id: "profile-1", + slug: "test-profile", + projectId: "project-1", + caId: "ca-1", + certificateTemplateId: "template-1", + enrollmentType: EnrollmentType.API, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test profile for algorithm compatibility", + estConfigId: null, + apiConfigId: null + }; + + const mockCertificateRequest = { + commonName: "test.example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully process RSA algorithms with RSA CAs", async () => { + const rsaCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA Test CA", + dn: "CN=RSA Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsaCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsaCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - RSA CA is compatible with RSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should successfully process ECDSA algorithms with EC CAs", async () => { + const ecCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC Test CA", + dn: "CN=EC Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_prime256v1", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(ecCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: ecCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - EC CA is compatible with ECDSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new RSA key sizes", async () => { + const rsa8192Ca = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA 8192 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA 8192 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA 8192 Test CA", + dn: "CN=RSA 8192 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_8192", // Future RSA key size + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA 8192 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA 8192 template for future key sizes", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsa8192Ca); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsa8192Ca as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new RSA key sizes + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new EC curve types", async () => { + const newEcCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC secp521r1 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC secp521r1 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC secp521r1 Test CA", + dn: "CN=EC secp521r1 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_secp521r1", // Future EC curve + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA secp521r1 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA secp521r1 template for future EC curves", + signatureAlgorithm: { + allowedAlgorithms: ["SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(newEcCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: newEcCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new EC curves + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA384" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts new file mode 100644 index 000000000..1c11b0a00 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -0,0 +1,487 @@ +import { ForbiddenError } from "@casl/ability"; +import { randomUUID } from "crypto"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { + TCertificateAuthorityDALFactory, + TCertificateAuthorityWithAssociatedCa +} from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + bufferToString, + buildCertificateSubjectFromTemplate, + buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayToLegacy, + mapEnumsForValidation, + normalizeDateForApi +} from "../certificate-common/certificate-utils"; +import { + TCertificateFromProfileResponse, + TCertificateOrderResponse, + TIssueCertificateFromProfileDTO, + TOrderCertificateFromProfileDTO, + TSignCertificateFromProfileDTO +} from "./certificate-v3-types"; + +type TCertificateV3ServiceFactoryDep = { + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + certificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + >; + internalCaService: Pick; + permissionService: Pick; +}; + +export type TCertificateV3ServiceFactory = ReturnType; + +const validateProfileAndPermissions = async ( + profileId: string, + actor: ActorType, + actorId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string, + certificateProfileDAL: Pick, + permissionService: Pick, + requiredEnrollmentType: EnrollmentType +) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== requiredEnrollmentType) { + throw new ForbiddenRequestError({ + message: `Profile is not configured for ${requiredEnrollmentType} enrollment` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + + return profile; +}; + +const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType !== CaType.INTERNAL) { + throw new BadRequestError({ message: `Only internal CAs support ${operation}` }); + } + return caType; +}; + +const validateAlgorithmCompatibility = ( + ca: TCertificateAuthorityWithAssociatedCa, + template: { + algorithms?: { + signature?: string[]; + }; + } +) => { + if (!template.algorithms?.signature || template.algorithms.signature.length === 0) { + return; + } + + const caKeyAlgorithm = ca.internalCa?.keyAlgorithm; + if (!caKeyAlgorithm) { + throw new BadRequestError({ message: "CA key algorithm not found" }); + } + + const compatibleAlgorithms = + template.algorithms?.signature?.filter((sigAlg: string) => { + const parts = sigAlg.split("-"); + if (parts.length === 0) { + return false; + } + const keyType = parts[parts.length - 1]; + + if (caKeyAlgorithm.startsWith("RSA")) { + return keyType === "RSA"; + } + + if (caKeyAlgorithm.startsWith("EC")) { + return keyType === "ECDSA"; + } + + return false; + }) || []; + + if (compatibleAlgorithms.length === 0) { + throw new BadRequestError({ + message: `Template signature algorithms (${template.algorithms?.signature?.join(", ") || "none"}) are not compatible with CA key algorithm (${caKeyAlgorithm})` + }); + } +}; + +const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => { + if (typeof certData === "string") return certData; + if (Buffer.isBuffer(certData)) return bufferToString(certData); + if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) { + return bufferToString(certData.rawData); + } + return bufferToString(certData as unknown as Buffer); +}; + +export const certificateV3ServiceFactory = ({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService, + permissionService +}: TCertificateV3ServiceFactoryDep) => { + const issueCertificateFromProfile = async ({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) { + throw new BadRequestError({ + message: "Common Name must be a single value, not an array" + }); + } + + const mappedCertificateRequest = mapEnumsForValidation({ + ...certificateRequest, + subjectAlternativeNames: certificateRequest.altNames + }); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; + const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template.sans + ); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: certificateSubject.common_name || "Certificate", + commonName: certificateSubject.common_name || "", + altNames: subjectAlternativeNames, + ttl: certificateRequest.validity.ttl, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + notBefore: normalizeDateForApi(certificateRequest.notBefore), + notAfter: normalizeDateForApi(certificateRequest.notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + return { + certificate: bufferToString(certificate), + issuingCaCertificate: bufferToString(issuingCaCertificate), + certificateChain: bufferToString(certificateChain), + privateKey: bufferToString(privateKey), + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const signCertificateFromProfile = async ({ + profileId, + csr, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignCertificateFromProfileDTO): Promise> => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "CSR signing"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); + } + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = signatureAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); + const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + + return { + certificate: certificateString, + issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), + certificateChain: certificateChainString, + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const orderCertificateFromProfile = async ({ + profileId, + certificateOrder, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TOrderCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, + value: san.value + })), + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate order validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + + if (caType === CaType.INTERNAL) { + const certificateResult = await issueCertificateFromProfile({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + const orderId = randomUUID(); + + return { + orderId, + status: CertificateOrderStatus.VALID, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type, + value: san.value, + status: CertificateOrderStatus.VALID + })), + authorizations: [], + finalize: `/api/v3/certificates/orders/${orderId}/completed`, + certificate: certificateResult.certificate, + projectId: certificateResult.projectId, + profileName: certificateResult.profileName + }; + } + + if (caType === CaType.ACME) { + throw new BadRequestError({ + message: "ACME certificate ordering via profiles is not yet implemented." + }); + } + + throw new BadRequestError({ + message: `Certificate ordering is not supported for CA type: ${caType}` + }); + }; + + return { + issueCertificateFromProfile, + signCertificateFromProfile, + orderCertificateFromProfile + }; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts new file mode 100644 index 000000000..b54042c5c --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -0,0 +1,99 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; + +export type TIssueCertificateFromProfileDTO = { + profileId: string; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TSignCertificateFromProfileDTO = { + profileId: string; + csr: string; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} & Omit; + +export type TOrderCertificateFromProfileDTO = { + profileId: string; + certificateOrder: { + altNames: Array<{ + type: ACMESANType; + value: string; + }>; + validity: { + ttl: string; + }; + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TCertificateFromProfileResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; + profileName: string; +}; + +export type TCertificateOrderResponse = { + orderId: string; + status: CertificateOrderStatus; + subjectAlternativeNames: Array<{ + type: ACMESANType; + value: string; + status: CertificateOrderStatus; + }>; + authorizations: Array<{ + identifier: { + type: ACMESANType; + value: string; + }; + status: CertificateOrderStatus; + expires?: string; + challenges: Array<{ + type: string; + status: CertificateOrderStatus; + url: string; + token: string; + }>; + }>; + finalize: string; + certificate?: string; + projectId: string; + profileName: string; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 527df2a39..9da331be8 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -8,14 +8,26 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; export enum CertStatus { ACTIVE = "active", + EXPIRED = "expired", REVOKED = "revoked" } export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", - ECDSA_P384 = "EC_secp384r1" + ECDSA_P384 = "EC_secp384r1", + ECDSA_P521 = "EC_secp521r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" } export enum CertKeyUsage { @@ -39,6 +51,11 @@ export enum CertExtendedKeyUsage { OCSP_SIGNING = "ocspSigning" } +export enum CertSignatureType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export const CertExtendedKeyUsageOIDToName: Record = { [x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH, [x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH, @@ -105,13 +122,48 @@ export type TGetCertificateCredentialsDTO = { kmsService: Pick; }; +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + export enum TAltNameType { EMAIL = "email", DNS = "dns", IP = "ip", URL = "url" } + +export const mapLegacyAltNameType = (legacyType: TAltNameType): CertSubjectAlternativeNameType => { + switch (legacyType) { + case TAltNameType.EMAIL: + return CertSubjectAlternativeNameType.EMAIL; + case TAltNameType.DNS: + return CertSubjectAlternativeNameType.DNS_NAME; + case TAltNameType.IP: + return CertSubjectAlternativeNameType.IP_ADDRESS; + case TAltNameType.URL: + return CertSubjectAlternativeNameType.URI; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown legacy alt name type: ${legacyType}`); + } +}; export type TAltNameMapping = { type: TAltNameType; value: string; }; + +export enum ACMESANType { + DNS = "dns", + IP = "ip" +} + +export enum CertificateOrderStatus { + PENDING = "pending", + PROCESSING = "processing", + VALID = "valid", + INVALID = "invalid" +} diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts new file mode 100644 index 000000000..1edfdae6c --- /dev/null +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -0,0 +1,118 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TApiEnrollmentConfigDALFactory = ReturnType; + +export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { + const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig); + + const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create API enrollment config" }); + } + }; + + const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update API enrollment config" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Delete API enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first(); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Find API enrollment config by id" }); + } + }; + + const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, projectId?: string, tx?: Knex) => { + try { + let query = (tx || db)(TableName.PkiCertificateProfile) + .join( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true); + + if (projectId) { + query = query.where(`${TableName.PkiCertificateProfile}.projectId`, projectId); + } + + const profiles = await query + .where((qb) => { + void qb + .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + }) + .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + + return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + } catch (error) { + throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); + } + }; + + const isConfigInUse = async (configId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.PkiCertificateProfile).where({ apiConfigId: configId }).count("*").first(); + + if (!doc || typeof doc !== "object") { + return 0; + } + + const countValue = (doc as Record).count; + if (typeof countValue === "number") { + return countValue; + } + if (typeof countValue === "string") { + const parsed = parseInt(countValue, 10); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "Check if API enrollment config is in use" }); + } + }; + + return { + ...apiEnrollmentConfigOrm, + create, + updateById, + deleteById, + findById, + findProfilesForAutoRenewal, + isConfigInUse + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts new file mode 100644 index 000000000..516f135fd --- /dev/null +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -0,0 +1,29 @@ +import { + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-api-enrollment-configs"; +import { + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-est-enrollment-configs"; + +export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs; +export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert; +export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate; + +export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; +export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; +export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; + +export interface TEstConfigData { + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain?: string; +} + +export interface TApiConfigData { + autoRenew: boolean; + autoRenewDays?: number; +} diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts new file mode 100644 index 000000000..b7520f290 --- /dev/null +++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TEstEnrollmentConfigDALFactory = ReturnType; + +export const estEnrollmentConfigDALFactory = (db: TDbClient) => { + const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig); + + const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + throw new Error("Failed to create EST enrollment config"); + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create EST enrollment config" }); + } + }; + + const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + return null; + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update EST enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); + + return estConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find EST enrollment config by id" }); + } + }; + + return { + ...estEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/docs/api-reference/endpoints/certificate-profiles/create.mdx b/docs/api-reference/endpoints/certificate-profiles/create.mdx new file mode 100644 index 000000000..e24e42207 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/delete.mdx b/docs/api-reference/endpoints/certificate-profiles/delete.mdx new file mode 100644 index 000000000..a1762640a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx new file mode 100644 index 000000000..38e0c20f8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx new file mode 100644 index 000000000..9013020d6 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Slug" +openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx new file mode 100644 index 000000000..d0a690f76 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Certificates" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list.mdx b/docs/api-reference/endpoints/certificate-profiles/list.mdx new file mode 100644 index 000000000..c0f461512 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/update.mdx b/docs/api-reference/endpoints/certificate-profiles/update.mdx new file mode 100644 index 000000000..e483cf030 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx new file mode 100644 index 000000000..2fb4da177 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx new file mode 100644 index 000000000..dc92ca55a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx new file mode 100644 index 000000000..c97389a1d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx new file mode 100644 index 000000000..ab752e851 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx new file mode 100644 index 000000000..7bdeca14e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx deleted file mode 100644 index 56fcf3791..000000000 --- a/docs/api-reference/endpoints/certificate-templates/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/certificate-templates" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx deleted file mode 100644 index c4f13d470..000000000 --- a/docs/api-reference/endpoints/certificate-templates/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx deleted file mode 100644 index 802dc5326..000000000 --- a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get by ID" -openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx deleted file mode 100644 index 53c5f6fdf..000000000 --- a/docs/api-reference/endpoints/certificate-templates/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx deleted file mode 100644 index 14a53b7fa..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/subscribers" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx deleted file mode 100644 index 5975b89e9..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx deleted file mode 100644 index 894c8ed4e..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve latest certificate bundle" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx deleted file mode 100644 index c9c71c80d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx deleted file mode 100644 index 3a4607303..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Certificates" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx deleted file mode 100644 index 93abf1433..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Order Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx deleted file mode 100644 index 0d223217d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/read.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx deleted file mode 100644 index d31d30239..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx deleted file mode 100644 index 5b62cbe7d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/docs.json b/docs/docs.json index 22b2564ed..7f25805d8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2452,20 +2452,6 @@ { "group": "Infisical PKI", "pages": [ - { - "group": "Subscribers", - "pages": [ - "api-reference/endpoints/pki/subscribers/list-certs", - "api-reference/endpoints/pki/subscribers/create", - "api-reference/endpoints/pki/subscribers/read", - "api-reference/endpoints/pki/subscribers/update", - "api-reference/endpoints/pki/subscribers/delete", - "api-reference/endpoints/pki/subscribers/issue-cert", - "api-reference/endpoints/pki/subscribers/sign-cert", - "api-reference/endpoints/pki/subscribers/order-cert", - "api-reference/endpoints/pki/subscribers/get-latest-cert-bundle" - ] - }, { "group": "Certificate Authorities", "pages": [ @@ -2522,10 +2508,11 @@ { "group": "Certificate Templates", "pages": [ - "api-reference/endpoints/certificate-templates/create", - "api-reference/endpoints/certificate-templates/update", - "api-reference/endpoints/certificate-templates/get-by-id", - "api-reference/endpoints/certificate-templates/delete" + "api-reference/endpoints/certificate-templates-v2/list", + "api-reference/endpoints/certificate-templates-v2/create", + "api-reference/endpoints/certificate-templates-v2/update", + "api-reference/endpoints/certificate-templates-v2/get-by-id", + "api-reference/endpoints/certificate-templates-v2/delete" ] }, { @@ -2549,6 +2536,15 @@ "api-reference/endpoints/pki-alerts/delete" ] }, + { + "group": "Certificate Profiles", + "pages": [ + "api-reference/endpoints/certificate-profiles/create", + "api-reference/endpoints/certificate-profiles/update", + "api-reference/endpoints/certificate-profiles/get-by-id", + "api-reference/endpoints/certificate-profiles/delete" + ] + }, { "group": "Certificate Syncs", "pages": [ diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 3a4dd11e6..0f4736d1a 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -291,6 +291,16 @@ Supports conditions and permission inversion | `create` | Issue new certificates | | `delete` | Revoke or remove certificates | +#### Subject: `certificate-profiles` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate profiles | +| `create` | Create new certificate profiles | +| `edit` | Modify profile configurations | +| `delete` | Remove certificate profiles | +| `issue-cert` | Issue new certificates | + #### Subject: `certificate-templates` | Action | Description | diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index a1669e18f..415a82641 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -4,6 +4,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d3019409f..0236113eb 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -293,6 +301,7 @@ export enum ProjectPermissionSub { PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", PkiSubscribers = "pki-subscribers", + CertificateProfiles = "certificate-profiles", Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", @@ -470,6 +479,7 @@ export type ProjectPermissionSet = | (ForcedSubject & PkiSubscriberSubjectFields) ) ] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index fff370269..3e0f95807 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -15,6 +15,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 843ded3e3..11d561e02 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] case ProjectType.SecretManager: return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers" as const; + return "/projects/cert-management/$projectId/policies" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; case ProjectType.PAM: diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 36526ec18..05a161c75 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -2,8 +2,10 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from export { useCreateCa, useCreateCertificate, + useCreateCertificateV3, useDeleteCa, useImportCaCertificate, + useOrderCertificateWithProfile, useRenewCa, useSignIntermediate, useUpdateCa @@ -21,3 +23,4 @@ export { useListCasByTypeAndProjectId, useListExternalCasByProjectId } from "./queries"; +export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index a14a0244b..fa422054c 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -9,9 +9,13 @@ import { TCreateCertificateAuthorityDTO, TCreateCertificateDTO, TCreateCertificateResponse, + TCreateCertificateV3DTO, + TCreateCertificateV3Response, TDeleteCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, + TOrderCertificateDTO, + TOrderCertificateResponse, TRenewCaDTO, TRenewCaResponse, TSignIntermediateDTO, @@ -148,6 +152,46 @@ export const useCreateCertificate = () => { }); }; +export const useCreateCertificateV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/issue-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles"] + }); + } + }); +}; + +export const useOrderCertificateWithProfile = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/order-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; + export const useRenewCa = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 0443dd5e9..696e72494 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -155,7 +155,7 @@ export type TCreateCertificateDTO = { pkiCollectionId?: string; friendlyName?: string; commonName: string; - altNames: string; // sans + subjectAltNames: string; // sans ttl: string; // string compatible with ms notBefore?: string; notAfter?: string; @@ -171,6 +171,84 @@ export type TCreateCertificateResponse = { serialNumber: string; }; +export type TCreateCertificateV3DTO = { + projectSlug: string; + profileId: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + subjectAltNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TCreateCertificateV3Response = TCreateCertificateResponse & { + projectId: string; + profileName: string; + certificateId: string; +}; + +export type TOrderCertificateDTO = { + projectSlug: string; + profileId: string; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + }>; + ttl: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + notBefore?: string; + notAfter?: string; + commonName?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TOrderCertificateResponse = { + orderId: string; + status: "pending" | "processing" | "valid" | "invalid"; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + status: "pending" | "processing" | "valid" | "invalid"; + }>; + authorizations: Array<{ + identifier: { + type: "dns" | "ip"; + value: string; + }; + status: "pending" | "processing" | "valid" | "invalid"; + expires?: string; + challenges: Array<{ + type: string; + status: "pending" | "processing" | "valid" | "invalid"; + url: string; + token: string; + validated?: string; + error?: string | Error; + }>; + }>; + certificate?: string; + privateKey?: string; + expires: string; + notBefore: string; + notAfter: string; +}; + export type TRenewCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts new file mode 100644 index 000000000..dc5c17efa --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -0,0 +1,14 @@ +export { + useCreateCertificateProfile, + useDeleteCertificateProfile, + useUpdateCertificateProfile +} from "./mutations"; +export { + certificateProfileKeys, + useGetCertificateProfileById, + useGetCertificateProfileBySlug, + useGetProfileCertificates, + useGetProfileMetrics, + useListCertificateProfiles +} from "./queries"; +export type * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/mutations.tsx b/frontend/src/hooks/api/certificateProfiles/mutations.tsx new file mode 100644 index 000000000..ca784ed0d --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/mutations.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { certificateProfileKeys } from "./queries"; +import { + TCertificateProfile, + TCreateCertificateProfileDTO, + TDeleteCertificateProfileDTO, + TUpdateCertificateProfileDTO +} from "./types"; + +export const useCreateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateProfile: TCertificateProfile; + }>("/api/v1/pki/certificate-profiles", data); + return response.certificateProfile; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`, data); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; + +export const useDeleteCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId }) => { + const { data: response } = await apiRequest.delete<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.removeQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx new file mode 100644 index 000000000..abdc93ddb --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -0,0 +1,162 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TCertificateProfile, + TCertificateProfileMetrics, + TCertificateProfileWithDetails, + TGetCertificateProfileByIdDTO, + TGetCertificateProfileBySlugDTO, + TGetProfileCertificatesDTO, + TGetProfileMetricsDTO, + TListCertificateProfilesDTO, + TProfileCertificate +} from "./types"; + +export const certificateProfileKeys = { + list: (params: { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: string; + expiringDays?: number; + }) => ["certificate-profiles", "list", params], + getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], + getBySlug: (projectId: string, slug: string) => [ + "certificate-profiles", + "get-by-slug", + projectId, + slug + ], + getCertificates: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "certificates", + profileId, + params + ], + getMetrics: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "metrics", + profileId, + params + ] +}; + +export const useListCertificateProfiles = ({ + projectId, + limit = 20, + offset = 0, + search, + includeMetrics = false, + includeConfigs = false, + enrollmentType, + expiringDays = 7 +}: TListCertificateProfilesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.list({ + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfiles: TCertificateProfile[]; + totalCount: number; + }>("/api/v1/pki/certificate-profiles", { + params: { + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getById(profileId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfileWithDetails; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return data.certificateProfile; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetCertificateProfileBySlug = ({ + projectId, + slug +}: TGetCertificateProfileBySlugDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getBySlug(projectId, slug), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/slug/${slug}`, { + params: { projectId } + }); + return data.certificateProfile; + }, + enabled: Boolean(projectId && slug) + }); +}; + +export const useGetProfileCertificates = ({ + profileId, + offset = 0, + limit = 20, + status, + search +}: TGetProfileCertificatesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificates: TProfileCertificate[]; + }>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, { + params: { + offset, + limit, + status, + search + } + }); + return data.certificates; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + metrics: TCertificateProfileMetrics; + }>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, { + params: { expiringDays } + }); + return data.metrics; + }, + enabled: Boolean(profileId) + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts new file mode 100644 index 000000000..a9b6b7060 --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -0,0 +1,130 @@ +export type TCertificateProfile = { + id: string; + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfigId?: string; + apiConfigId?: string; + createdAt: string; + updatedAt: string; + metrics?: TCertificateProfileMetrics; +}; + +export type TCertificateProfileWithDetails = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; +}; + +export type TCreateCertificateProfileDTO = { + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TUpdateCertificateProfileDTO = { + profileId: string; + slug?: string; + description?: string; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TDeleteCertificateProfileDTO = { + profileId: string; +}; + +export type TListCertificateProfilesDTO = { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: "api" | "est"; + expiringDays?: number; +}; + +export type TGetCertificateProfileByIdDTO = { + profileId: string; +}; + +export type TGetCertificateProfileBySlugDTO = { + projectId: string; + slug: string; +}; + +export type TCertificateProfileMetrics = { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +}; + +export type TProfileCertificate = { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: string; + notAfter: string; + isRevoked: boolean; + createdAt: string; +}; + +export type TGetProfileCertificatesDTO = { + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; +}; + +export type TGetProfileMetricsDTO = { + profileId: string; + expiringDays?: number; +}; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 24a7d0e5f..0355d9d87 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,13 +7,17 @@ import { projectKeys } from "../projects"; import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, + TCertificateTemplateV2WithPolicies, TCreateCertificateTemplateDTO, TCreateCertificateTemplateV2DTO, + TCreateCertificateTemplateV2WithPoliciesDTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, TDeleteCertificateTemplateV2DTO, + TDeleteCertificateTemplateV2WithPoliciesDTO, TUpdateCertificateTemplateDTO, TUpdateCertificateTemplateV2DTO, + TUpdateCertificateTemplateV2WithPoliciesDTO, TUpdateEstConfigDTO } from "./types"; @@ -163,3 +167,72 @@ export const useUpdateEstConfig = () => { } }); }; + +export const useCreateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TCreateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>("/api/v2/certificate-templates", data); + return response.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TUpdateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`, data); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; + +export const useDeleteCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TDeleteCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId }) => { + const { data: response } = await apiRequest.delete<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.removeQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index 435345ad9..383f4ed71 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate, TCertificateTemplateV2, + TCertificateTemplateV2WithPolicies, TEstConfig, - TListCertificateTemplatesDTO + TGetCertificateTemplateV2ByIdDTO, + TListCertificateTemplatesDTO, + TListCertificateTemplatesV2DTO } from "./types"; export const certTemplateKeys = { @@ -16,7 +19,16 @@ export const certTemplateKeys = { projectId, el ], - getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] + getEstConfig: (id: string) => [{ id }, "cert-template-est-config"], + listTemplatesV2: ({ + projectId, + ...el + }: { + limit?: number; + offset?: number; + projectId: string; + }) => ["list-templates-v2", projectId, el], + getTemplateV2ById: (id: string) => ["cert-template-v2", id] }; export const useGetCertTemplate = (id: string) => { @@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => { enabled: Boolean(certificateTemplateId) }); }; + +export const useListCertificateTemplatesV2 = ({ + projectId, + limit = 20, + offset = 0 +}: TListCertificateTemplatesV2DTO) => { + return useQuery({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TCertificateTemplateV2WithPolicies[]; + totalCount: number; + }>("/api/v2/certificate-templates", { + params: { + projectId, + limit, + offset + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateTemplateV2ById = ({ + templateId +}: TGetCertificateTemplateV2ByIdDTO) => { + return useQuery({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return data.certificateTemplate; + }, + enabled: Boolean(templateId) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 1c2a47178..373bbef2a 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -121,3 +121,90 @@ export type TListCertificateTemplatesDTO = { offset?: number; projectId: string; }; + +export type TCertificateTemplateV2Policy = { + subject?: Array<{ + type: "common_name" | "organization" | "country"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: "dns_name" | "ip_address" | "email" | "uri"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + extendedKeyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + algorithms?: { + signature?: Array< + "SHA256-RSA" | "SHA384-RSA" | "SHA512-RSA" | "SHA256-ECDSA" | "SHA384-ECDSA" | "SHA512-ECDSA" + >; + keyAlgorithm?: Array<"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384">; + }; + validity?: { + max?: string; + }; +}; + +export type TCertificateTemplateV2WithPolicies = { + id: string; + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCertificateTemplateV2WithPoliciesDTO = { + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TUpdateCertificateTemplateV2WithPoliciesDTO = { + templateId: string; + name?: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TDeleteCertificateTemplateV2WithPoliciesDTO = { + templateId: string; +}; + +export type TListCertificateTemplatesV2DTO = { + projectId: string; + limit?: number; + offset?: number; +}; + +export type TGetCertificateTemplateV2ByIdDTO = { + templateId: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 0384ea6cd..8647fafd5 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => { export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_3072]: "RSA 3072", [CertKeyAlgorithm.RSA_4096]: "RSA 4096", [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" @@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { export const certKeyAlgorithms = [ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], @@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [ { value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" }, { value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" } ] as const; + +export const SIGNATURE_ALGORITHMS_OPTIONS = [ + { value: "RSA-SHA256", label: "RSA-SHA256" }, + { value: "RSA-SHA384", label: "RSA-SHA384" }, + { value: "RSA-SHA512", label: "RSA-SHA512" }, + { value: "ECDSA-SHA256", label: "ECDSA-SHA256" }, + { value: "ECDSA-SHA384", label: "ECDSA-SHA384" }, + { value: "ECDSA-SHA512", label: "ECDSA-SHA512" } +] as const; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index 566da7506..6ee03c308 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -5,6 +5,7 @@ export enum CertStatus { export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1" @@ -24,22 +25,22 @@ export enum CrlReason { } export enum CertKeyUsage { - DIGITAL_SIGNATURE = "digitalSignature", - KEY_ENCIPHERMENT = "keyEncipherment", - NON_REPUDIATION = "nonRepudiation", - DATA_ENCIPHERMENT = "dataEncipherment", - KEY_AGREEMENT = "keyAgreement", - KEY_CERT_SIGN = "keyCertSign", - CRL_SIGN = "cRLSign", - ENCIPHER_ONLY = "encipherOnly", - DECIPHER_ONLY = "decipherOnly" + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" } export enum CertExtendedKeyUsage { - CLIENT_AUTH = "clientAuth", - SERVER_AUTH = "serverAuth", - CODE_SIGNING = "codeSigning", - EMAIL_PROTECTION = "emailProtection", - TIMESTAMPING = "timeStamping", - OCSP_SIGNING = "ocspSigning" + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + TIMESTAMPING = "time_stamping", + OCSP_SIGNING = "ocsp_signing" } diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 77a3dab72..388295b0a 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -52,6 +52,10 @@ export const useRevokeCert = () => { queryClient.invalidateQueries({ queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); } }); }; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index c1dd59eca..1ec3292a3 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -7,7 +7,7 @@ export type TCertificate = { status: CertStatus; friendlyName: string; commonName: string; - altNames: string; + subjectAltNames: string; serialNumber: string; notBefore: string; notAfter: string; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ede2f8cf1..5c0fa687b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -47,6 +47,7 @@ export type SubscriptionPlan = { gateway: boolean; externalKms: boolean; pkiEst: boolean; + pkiLegacyTemplates: boolean; enforceMfa: boolean; enforceGoogleSSO: boolean; projectTemplates: boolean; diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 5f99a1f90..2c088c293 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -5,15 +5,31 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Tab, TabList, Tabs } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; +import { + useListWorkspaceCertificateTemplates, + useListWorkspacePkiSubscribers +} from "@app/hooks/api"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; export const PkiManagerLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const { subscription } = useSubscription(); const { t } = useTranslation(); + const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + projectId: currentProject?.id || "" + }); + const templates = templatesData?.certificateTemplates || []; + + const hasExistingSubscribers = subscribers.length > 0; + const hasExistingTemplates = templates.length > 0; + const showLegacySection = + subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates; + const location = useLocation(); return ( <> @@ -31,22 +47,12 @@ export const PkiManagerLayout = () => { - {({ isActive }) => Subscribers} - - - {({ isActive }) => ( - Certificate Templates - )} + {({ isActive }) => Policies} { App Connections )} + {showLegacySection && ( + <> + {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( + + {({ isActive }) => ( + Subscribers (Legacy) + )} + + )} + {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( + + {({ isActive }) => ( + + Certificate Templates (Legacy) + + )} + + )} + + )} { maxPathLength: ca.configuration.maxPathLength ? String(ca.configuration.maxPathLength) : "", - keyAlgorithm: ca.configuration.keyAlgorithm + keyAlgorithm: Object.values(CertKeyAlgorithm).includes( + ca.configuration.keyAlgorithm as CertKeyAlgorithm + ) + ? ca.configuration.keyAlgorithm + : CertKeyAlgorithm.RSA_2048 } }); } else { @@ -153,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { type: CaType.INTERNAL, name: "", status: CaStatus.ACTIVE, - enableDirectIssuance: true, + enableDirectIssuance: false, configuration: { type: InternalCaType.ROOT, organization: "", @@ -457,23 +455,6 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Enable Direct Issuance

-
-
- ); - }} - />
+ } + errorText={error?.message} + isError={Boolean(error)} + isRequired + > + + + )} + /> + )} + + {(actualSelectedProfile || profileId) && ( + <> + {constraints.shouldShowSubjectSection && ( + ( + + { + onChange([{ type: "common_name", value: e.target.value }]); + }} + placeholder="example.com" + /> + + )} + /> + )} + + {constraints.shouldShowSanSection && ( + + )} + + ( + + + + )} + /> + + + + + + + + + )} + +
+ + +
+ + )} + + + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx index 918aa7303..22718222a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx @@ -46,9 +46,8 @@ const schema = z.object({ certificateTemplateId: z.string().optional(), caId: z.string(), collectionId: z.string().optional(), - friendlyName: z.string(), commonName: z.string().trim().min(1), - altNames: z.string(), + subjectAltNames: z.string(), ttl: z.string().trim(), keyUsages: z.object({ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), @@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { if (cert) { reset({ caId: cert.caId, - friendlyName: cert.friendlyName, commonName: cert.commonName, - altNames: cert.altNames, + subjectAltNames: cert.subjectAltNames, certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, ttl: "", keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), @@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } else { reset({ caId: "", - friendlyName: "", commonName: "", - altNames: "", + subjectAltNames: "", ttl: "", certificateTemplateId: CERT_TEMPLATE_NONE_VALUE, keyUsages: { @@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ caId, - friendlyName, collectionId, commonName, - altNames, + subjectAltNames, ttl, keyUsages, extendedKeyUsages @@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, projectSlug: currentProject.slug, pkiCollectionId: collectionId, - friendlyName, commonName, - altNames, + subjectAltNames, ttl, keyUsages: Object.entries(keyUsages) .filter(([, value]) => value) @@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { /> )} - ( - - - - )} - /> { ( { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { mutateAsync: deleteCert } = useDeleteCert(); + const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateIssuance", "certificate", "certificateImport", "certificateCert", @@ -73,7 +79,9 @@ export const CertificatesSection = () => { colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("certificate")} + onClick={() => + handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance") + } isDisabled={!isAllowed} > Issue @@ -83,7 +91,11 @@ export const CertificatesSection = () => { - + {isLegacyTemplatesEnabled ? ( + + ) : ( + + )} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx new file mode 100644 index 000000000..747c6c09f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx @@ -0,0 +1,78 @@ +import { Control, Controller } from "react-hook-form"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Checkbox, + FormLabel +} from "@app/components/v2"; + +type KeyUsageOption = { + label: string; + value: string; +}; + +type KeyUsageSectionProps = { + control: Control; + title: string; + accordionValue: string; + namePrefix: "keyUsages" | "extendedKeyUsages"; + options: KeyUsageOption[]; + requiredUsages: string[]; +}; + +export const KeyUsageSection = ({ + control, + title, + accordionValue, + namePrefix, + options, + requiredUsages +}: KeyUsageSectionProps) => { + if (options.length === 0) return null; + + return ( + + {title} + +
+ {options.map(({ label, value }) => { + const isRequired = requiredUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx new file mode 100644 index 000000000..4614539e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx @@ -0,0 +1,99 @@ +import { Control, Controller } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +import { getSanPlaceholder, getSanTypeLabels, SubjectAltName } from "./certificateUtils"; + +type SubjectAltNamesFieldProps = { + control: Control; + allowedSanTypes: CertSubjectAlternativeNameType[]; + error?: string; +}; + +export const SubjectAltNamesField = ({ + control, + allowedSanTypes, + error +}: SubjectAltNamesFieldProps) => { + const sanTypeLabels = getSanTypeLabels(); + + return ( + ( + +
+ {value.map((san: SubjectAltName, index: number) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={getSanPlaceholder(san.type)} + className="flex-1" + /> + { + const newValue = value.filter((_: any, i: number) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
+
+ )} + /> + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts new file mode 100644 index 000000000..7f16b87e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts @@ -0,0 +1,51 @@ +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export const getSanPlaceholder = (sanType: CertSubjectAlternativeNameType): string => { + switch (sanType) { + case CertSubjectAlternativeNameType.DNS_NAME: + return "example.com or *.example.com"; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return "192.168.1.1"; + case CertSubjectAlternativeNameType.EMAIL: + return "admin@example.com"; + case CertSubjectAlternativeNameType.URI: + return "https://example.com"; + default: + return "Enter value"; + } +}; + +export const getSanTypeLabels = () => ({ + [CertSubjectAlternativeNameType.DNS_NAME]: "DNS", + [CertSubjectAlternativeNameType.IP_ADDRESS]: "IP", + [CertSubjectAlternativeNameType.EMAIL]: "Email", + [CertSubjectAlternativeNameType.URI]: "URI" +}); + +export type SubjectAltName = { + type: CertSubjectAlternativeNameType; + value: string; +}; + +export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => { + return subjectAltNames + .filter((san) => san.value.trim()) + .map((san) => ({ + type: san.type, + value: san.value.trim() + })); +}; + +export const filterUsages = >(usages: T): string[] => { + return Object.entries(usages) + .filter(([, value]) => value) + .map(([key]) => key); +}; + +export const getAttributeValue = ( + subjectAttributes: Array<{ type: string; value: string }> | undefined, + type: string +): string => { + const foundAttr = subjectAttributes?.find((attr) => attr.type === type); + return foundAttr?.value || ""; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts new file mode 100644 index 000000000..871ad00a4 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; + +import { + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { + CertSubjectAlternativeNameType, + mapTemplateKeyAlgorithmToApi, + mapTemplateSignatureAlgorithmToApi +} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export type TemplateConstraints = { + allowedKeyUsages: string[]; + allowedExtendedKeyUsages: string[]; + requiredKeyUsages: string[]; + requiredExtendedKeyUsages: string[]; + allowedSignatureAlgorithms: string[]; + allowedKeyAlgorithms: string[]; + allowedSanTypes: CertSubjectAlternativeNameType[]; + shouldShowSanSection: boolean; + shouldShowSubjectSection: boolean; +}; + +export const useCertificateTemplate = ( + templateData: any, + selectedProfile: any, + isModalOpen: boolean, + setValue: UseFormSetValue, + watch: UseFormWatch +) => { + const [constraints, setConstraints] = useState({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + + const filteredKeyUsages = useMemo(() => { + return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value)); + }, [constraints.allowedKeyUsages]); + + const filteredExtendedKeyUsages = useMemo(() => { + return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => + constraints.allowedExtendedKeyUsages.includes(value) + ); + }, [constraints.allowedExtendedKeyUsages]); + + const availableSignatureAlgorithms = useMemo(() => { + return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedSignatureAlgorithms]); + + const availableKeyAlgorithms = useMemo(() => { + return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedKeyAlgorithms]); + + const resetConstraints = () => { + setConstraints({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + }; + + useEffect(() => { + if (templateData && selectedProfile && isModalOpen) { + const newConstraints: TemplateConstraints = { + allowedSignatureAlgorithms: templateData.algorithms?.signature || [], + allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [], + allowedKeyUsages: [ + ...(templateData.keyUsages?.required || []), + ...(templateData.keyUsages?.allowed || []) + ], + allowedExtendedKeyUsages: [ + ...(templateData.extendedKeyUsages?.required || []), + ...(templateData.extendedKeyUsages?.allowed || []) + ], + requiredKeyUsages: templateData.keyUsages?.required || [], + requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [], + allowedSanTypes: [], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }; + + // Set TTL if available + if (templateData.validity?.max) { + setValue("ttl", templateData.validity.max); + } + + // Handle SAN types + if (templateData.sans && templateData.sans.length > 0) { + const sanTypes: CertSubjectAlternativeNameType[] = []; + templateData.sans.forEach((sanPolicy: any) => { + if (!sanTypes.includes(sanPolicy.type)) { + sanTypes.push(sanPolicy.type); + } + }); + newConstraints.allowedSanTypes = sanTypes; + newConstraints.shouldShowSanSection = true; + } else { + newConstraints.allowedSanTypes = []; + newConstraints.shouldShowSanSection = false; + setValue("subjectAltNames", []); + } + + // Handle subject section + if (templateData.subject && templateData.subject.length > 0) { + newConstraints.shouldShowSubjectSection = true; + const currentSubjectAttrs = watch("subjectAttributes"); + if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) { + setValue("subjectAttributes", [{ type: "common_name", value: "" }]); + } + } else { + newConstraints.shouldShowSubjectSection = false; + setValue("subjectAttributes", undefined); + } + + setConstraints(newConstraints); + + // Set initial required usages + const initialKeyUsages: Record = {}; + const initialExtendedKeyUsages: Record = {}; + + (templateData.keyUsages?.required || []).forEach((usage: string) => { + initialKeyUsages[usage] = true; + }); + + (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { + initialExtendedKeyUsages[usage] = true; + }); + + setValue("keyUsages", initialKeyUsages); + setValue("extendedKeyUsages", initialExtendedKeyUsages); + } + }, [templateData, selectedProfile, setValue, watch, isModalOpen]); + + return { + constraints, + filteredKeyUsages, + filteredExtendedKeyUsages, + availableSignatureAlgorithms, + availableKeyAlgorithms, + resetConstraints + }; +}; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index 984ef45ae..cb4b9ef39 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, - useProject + useProject, + useSubscription } from "@app/context"; import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api"; import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types"; @@ -18,7 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable"; export const PkiSubscriberSection = () => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const projectId = currentProject.id; + + const canCreateLegacySubscribers = subscription.pkiLegacyTemplates; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); @@ -100,23 +104,25 @@ export const PkiSubscriberSection = () => { /> - - {(isAllowed) => ( - - )} - + {canCreateLegacySubscribers && ( + + {(isAllowed) => ( + + )} + + )} diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx index 70bac6ecf..440ebb481 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx @@ -59,6 +59,7 @@ export const PkiTemplateListPage = () => { const { currentProject } = useProject(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "certificateTemplate", "deleteTemplate", @@ -113,28 +114,30 @@ export const PkiTemplateListPage = () => { />
-
-

Templates

-
- - {(isAllowed) => ( - - )} - + {subscription?.pkiLegacyTemplates && ( +
+

Templates

+
+ + {(isAllowed) => ( + + )} + +
-
+ )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx new file mode 100644 index 000000000..f6bc792fe --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { ProjectType } from "@app/hooks/api/projects/types"; + +import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; +import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; + +enum TabSections { + CertificateProfiles = "profiles", + CertificateTemplatesV2 = "templates-v2" +} + +export const PoliciesPage = () => { + const { t } = useTranslation(); + const { currentProject } = useProject(); + const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles); + + if (!currentProject) { + return ; + } + + return ( + + {(isAllowed) => { + if (!isAllowed) { + return ( +
+
+

You don't have permission to access certificate policies.

+
+
+ ); + } + + return ( +
+ + {t("common.head-title", { title: "Certificate Policies" })} + +
+ + + setActiveTab(value as TabSections)} + > + + + Certificate Profiles + + + Certificate Templates + + + + + + + + + + + +
+
+ ); + }} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx new file mode 100644 index 000000000..d034aeda1 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { + TCertificateProfileWithDetails, + useDeleteCertificateProfile +} from "@app/hooks/api/certificateProfiles"; + +import { CreateProfileModal } from "./CreateProfileModal"; +import { ProfileList } from "./ProfileList"; + +export const CertificateProfilesTab = () => { + const { permission } = useProjectPermission(); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedProfile, setSelectedProfile] = useState( + null + ); + + const deleteProfile = useDeleteCertificateProfile(); + + const canCreateProfile = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const handleCreateProfile = () => { + setIsCreateModalOpen(true); + }; + + const handleEditProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsEditModalOpen(true); + }; + + const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedProfile) return; + + try { + await deleteProfile.mutateAsync({ + profileId: selectedProfile.id + }); + setIsDeleteModalOpen(false); + setSelectedProfile(null); + createNotification({ + text: `Certificate profile "${selectedProfile.slug}" deleted successfully`, + type: "success" + }); + } catch (error) { + console.error( + `Failed to delete profile "${selectedProfile.slug}" (ID: ${selectedProfile.id}):`, + error + ); + } + }; + + return ( +
+
+
+

Certificate Profiles

+

+ Unified certificate issuance configurations combining CA, template, and enrollment + method +

+
+ + {canCreateProfile && ( + + )} +
+ + + + setIsCreateModalOpen(false)} /> + + {selectedProfile && ( + <> + { + setIsEditModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + mode="edit" + /> + + { + setIsDeleteModalOpen(isOpen); + if (!isOpen) { + setSelectedProfile(null); + } + }} + deleteKey={selectedProfile.slug} + onDeleteApproved={handleDeleteConfirm} + /> + + )} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx new file mode 100644 index 000000000..3f7553f83 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -0,0 +1,609 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; +import { + TCertificateProfileWithDetails, + TCreateCertificateProfileDTO, + TUpdateCertificateProfileDTO, + useCreateCertificateProfile, + useUpdateCertificateProfile +} from "@app/hooks/api/certificateProfiles"; +import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; + +const createSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + certificateTemplateId: z.string().min(1, "Certificate Template is required"), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().min(1, "EST passphrase is required"), + caChain: z.string().min(1, "EST CA chain is required").optional() + }) + .refine( + (data) => { + if (!data.disableBootstrapCaValidation && !data.caChain) { + return false; + } + return true; + }, + { + message: "EST CA chain is required when bootstrap CA validation is enabled", + path: ["caChain"] + } + ) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +const editSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().optional(), + certificateTemplateId: z.string().optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +export type FormData = z.infer; + +interface Props { + isOpen: boolean; + onClose: () => void; + profile?: TCertificateProfileWithDetails; + mode?: "create" | "edit"; +} + +export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { + const { currentProject } = useProject(); + + const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: templateData } = useListCertificateTemplatesV2({ + projectId: currentProject?.id || "", + limit: 100, + offset: 0 + }); + + const createProfile = useCreateCertificateProfile(); + const updateProfile = useUpdateCertificateProfile(); + + const isEdit = mode === "edit" && profile; + + const certificateAuthorities = caData || []; + const certificateTemplates = templateData?.certificateTemplates || []; + + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(isEdit ? editSchema : createSchema), + defaultValues: isEdit + ? { + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + } + : { + slug: "", + description: "", + enrollmentType: "api", + certificateAuthorityId: "", + certificateTemplateId: "", + apiConfig: { + autoRenew: false, + autoRenewDays: 30 + } + } + }); + + const watchedEnrollmentType = watch("enrollmentType"); + const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); + const watchedAutoRenew = watch("apiConfig.autoRenew"); + + useEffect(() => { + if (isEdit && profile) { + reset({ + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + }); + } + }, [isEdit, profile, reset]); + + const onFormSubmit = async (data: FormData) => { + try { + if (!currentProject?.id && !isEdit) return; + + if (isEdit) { + const updateData: TUpdateCertificateProfileDTO = { + profileId: profile.id, + slug: data.slug, + description: data.description + }; + + if (data.enrollmentType === "est" && data.estConfig) { + updateData.estConfig = data.estConfig; + } else if (data.enrollmentType === "api" && data.apiConfig) { + updateData.apiConfig = data.apiConfig; + } + + await updateProfile.mutateAsync(updateData); + } else { + if (!currentProject?.id) { + throw new Error("Project ID is required for creating a profile"); + } + + const createData: TCreateCertificateProfileDTO = { + projectId: currentProject.id, + slug: data.slug, + description: data.description, + enrollmentType: data.enrollmentType, + caId: data.certificateAuthorityId, + certificateTemplateId: data.certificateTemplateId + }; + + if (data.enrollmentType === "est" && data.estConfig) { + createData.estConfig = { + passphrase: data.estConfig.passphrase, + caChain: data.estConfig.caChain || undefined, + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation + }; + } else if (data.enrollmentType === "api" && data.apiConfig) { + createData.apiConfig = data.apiConfig; + } + + await createProfile.mutateAsync(createData); + } + + createNotification({ + text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`, + type: "success" + }); + + reset(); + onClose(); + } catch (error) { + console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error); + createNotification({ + text: `Failed to ${isEdit ? "update" : "create"} certificate profile`, + type: "error" + }); + } + }; + + return ( + { + if (!open) { + reset(); + } + onClose(); + }} + > + +
+ ( + + + + )} + /> + + ( + +