diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts index 90b727f68..718ace6c2 100644 --- a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -8,7 +8,7 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable(TableName.CertificateTemplateV2, (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.foreign("projectId").references("id").inTable(TableName.Project); t.string("slug").notNullable(); t.string("description"); @@ -60,7 +60,7 @@ export async function up(knex: Knex): Promise { await knex.schema.createTable(TableName.CertificateProfile, (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.foreign("projectId").references("id").inTable(TableName.Project); t.uuid("caId").notNullable(); t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); 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 cb63cd0f7..f3c95e434 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2569,19 +2569,41 @@ interface GetCertificateTemplateEstConfig { interface CreateCertificateTemplate { type: EventType.CREATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - name: string; - projectId: string; - }; + 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; - }; + metadata: + | { + certificateTemplateId: string; + name: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; } interface DeleteCertificateTemplate { @@ -2629,6 +2651,7 @@ interface DeleteCertificateProfile { type: EventType.DELETE_CERTIFICATE_PROFILE; metadata: { certificateProfileId: string; + name: string; }; } @@ -2636,6 +2659,7 @@ interface GetCertificateProfile { type: EventType.GET_CERTIFICATE_PROFILE; metadata: { certificateProfileId: string; + name: string; }; } @@ -2652,6 +2676,7 @@ interface IssueCertificateFromProfile { certificateProfileId: string; certificateId: string; commonName: string; + profileName: string; }; } @@ -2660,6 +2685,8 @@ interface SignCertificateFromProfile { metadata: { certificateProfileId: string; certificateId: string; + profileName: string; + commonName: string; }; } @@ -2668,7 +2695,7 @@ interface OrderCertificateFromProfile { metadata: { certificateProfileId: string; orderId: string; - subjectAlternativeNames: string[]; + profileName: string; }; } diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 695db488f..bb62440c1 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1119,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/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 70b1cf830..be36390e7 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { CertificateProfilesSchema } from "@app/db/schemas"; @@ -6,12 +7,7 @@ import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - createCertificateProfileSchema, - deleteCertificateProfileSchema, - listCertificateProfilesSchema, - updateCertificateProfileSchema -} from "@app/services/certificate-profile/certificate-profile-schemas"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { server.route({ @@ -23,7 +19,57 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.PkiCertificateProfiles], - body: createCertificateProfileSchema, + 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), + 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." + } + ), response: { 200: z.object({ certificateProfile: CertificateProfilesSchema @@ -68,7 +114,13 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.PkiCertificateProfiles], - querystring: listCertificateProfilesSchema.extend({ + 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) }), @@ -209,7 +261,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid event: { type: EventType.GET_CERTIFICATE_PROFILE, metadata: { - certificateProfileId: certificateProfile.id + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug } } }); @@ -266,7 +319,48 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid params: z.object({ id: z.string().min(1) }), - body: updateCertificateProfileSchema, + 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), + 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." + } + ), response: { 200: z.object({ certificateProfile: CertificateProfilesSchema @@ -309,7 +403,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.PkiCertificateProfiles], - params: deleteCertificateProfileSchema, + params: z.object({ + id: z.string().uuid() + }), response: { 200: z.object({ certificateProfile: CertificateProfilesSchema @@ -332,7 +428,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid event: { type: EventType.DELETE_CERTIFICATE_PROFILE, metadata: { - certificateProfileId: certificateProfile.id + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug } } }); diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index cd615b673..5ff0e39c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -117,7 +117,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.CREATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl, projectId: certificateTemplate.projectId } } @@ -181,7 +186,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.name + name: certificateTemplate.name, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl } } }); diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts index 6cf26800d..d13a9ad61 100644 --- a/backend/src/server/routes/v2/certificate-templates-v2-router.ts +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -4,18 +4,107 @@ import { CertificateTemplatesV2Schema } 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 { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { - certificateRequestSchema, - createCertificateTemplateV2Schema, - deleteCertificateTemplateV2Schema, - getCertificateTemplateV2ByIdSchema, - listCertificateTemplatesV2Schema, - updateCertificateTemplateV2Schema -} from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + CertDurationUnit, + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { + const templateV2AttributeSchema = z + .object({ + type: z.nativeEnum(CertSubjectAttributeType), + include: z.nativeEnum(CertIncludeType), + value: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (data.type === CertSubjectAttributeType.COMMON_NAME && data.value && data.value.length > 1) { + return false; + } + if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) { + return false; + } + return true; + }, + { + message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)" + } + ); + + const templateV2KeyUsagesSchema = z.object({ + requiredUsages: z + .object({ + all: z.array(z.nativeEnum(CertKeyUsageType)) + }) + .optional(), + optionalUsages: z + .object({ + all: z.array(z.nativeEnum(CertKeyUsageType)) + }) + .optional() + }); + + const templateV2ExtendedKeyUsagesSchema = z.object({ + requiredUsages: z + .object({ + all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) + }) + .optional(), + optionalUsages: z + .object({ + all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) + }) + .optional() + }); + + const templateV2SanSchema = z + .object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + include: z.nativeEnum(CertIncludeType), + value: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) { + return false; + } + return true; + }, + { + message: "Mandatory SANs can only have one value or no value (empty)" + } + ); + + const templateV2ValiditySchema = z.object({ + maxDuration: z.object({ + value: z.number().positive(), + unit: z.nativeEnum(CertDurationUnit) + }), + minDuration: z + .object({ + value: z.number().positive(), + unit: z.nativeEnum(CertDurationUnit) + }) + .optional() + }); + + const templateV2SignatureAlgorithmSchema = z.object({ + allowedAlgorithms: z.array(z.string()).min(1), + defaultAlgorithm: z.string() + }); + + const templateV2KeyAlgorithmSchema = z.object({ + allowedKeyTypes: z.array(z.string()).min(1), + defaultKeyType: z.string() + }); + server.route({ method: "POST", url: "/", @@ -25,7 +114,36 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - body: createCertificateTemplateV2Schema, + body: z + .object({ + projectId: z.string().min(1), + slug: slugSchema({ min: 1, max: 255 }), + description: z.string().max(1000).optional(), + attributes: z.array(templateV2AttributeSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + subjectAlternativeNames: z.array(templateV2SanSchema).optional(), + validity: templateV2ValiditySchema.optional(), + signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(), + keyAlgorithm: templateV2KeyAlgorithmSchema.optional() + }) + .refine( + (data) => { + const hasConstraints = + (data.attributes && data.attributes.length > 0) || + (data.subjectAlternativeNames && data.subjectAlternativeNames.length > 0) || + data.keyUsages || + data.extendedKeyUsages || + data.validity || + data.signatureAlgorithm || + data.keyAlgorithm; + return hasConstraints; + }, + { + message: + "Certificate template must define at least one constraint (attributes, SANs, key usages, validity, or algorithms)" + } + ), response: { 200: z.object({ certificateTemplate: CertificateTemplatesV2Schema @@ -70,7 +188,12 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - querystring: listCertificateTemplatesV2Schema, + 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: CertificateTemplatesV2Schema.array(), @@ -112,7 +235,9 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - params: getCertificateTemplateV2ByIdSchema, + params: z.object({ + id: z.string().uuid() + }), response: { 200: z.object({ certificateTemplate: CertificateTemplatesV2Schema @@ -154,8 +279,20 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - params: getCertificateTemplateV2ByIdSchema, - body: updateCertificateTemplateV2Schema, + params: z.object({ + id: z.string().uuid() + }), + body: z.object({ + slug: slugSchema({ min: 1, max: 255 }).optional(), + description: z.string().max(1000).optional(), + attributes: z.array(templateV2AttributeSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + subjectAlternativeNames: z.array(templateV2SanSchema).optional(), + validity: templateV2ValiditySchema.optional(), + signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(), + keyAlgorithm: templateV2KeyAlgorithmSchema.optional() + }), response: { 200: z.object({ certificateTemplate: CertificateTemplatesV2Schema @@ -198,7 +335,9 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - params: deleteCertificateTemplateV2Schema, + params: z.object({ + id: z.string().uuid() + }), response: { 200: z.object({ certificateTemplate: CertificateTemplatesV2Schema @@ -230,38 +369,4 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro return { certificateTemplate }; } }); - - server.route({ - method: "POST", - url: "/:id/validate", - config: { - rateLimit: readLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateTemplates], - params: getCertificateTemplateV2ByIdSchema, - body: z.object({ - request: certificateRequestSchema - }), - response: { - 200: z.object({ - valid: z.boolean(), - errors: z.array(z.string()).optional() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const result = await server.services.certificateTemplateV2.validateCertificateRequest( - req.params.id, - req.body.request - ); - - return { - valid: result.isValid, - errors: result.errors.length > 0 ? result.errors : undefined - }; - } - }); }; diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 19fa131b6..d5c1a7a90 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -6,12 +6,22 @@ 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 { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; import { validateAltNamesField, validateAndMapAltNameType, 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"; @@ -25,18 +35,43 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => schema: { hide: false, tags: [ApiDocsTags.PkiCertificates], - body: z.object({ - profileId: z.string().uuid(), - commonName: validateTemplateRegexField, - ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), - keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), - extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), - notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), - altNames: validateAltNamesField.optional(), - signatureAlgorithm: z.string().optional(), - keyAlgorithm: z.string().optional() - }), + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField, + ttl: z.string().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(), + subjectAltNames: validateAltNamesField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine( + (data) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); + }, + { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + } + ) + .refine( + (data) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; + }, + { + message: "notBefore must be earlier than notAfter" + } + ), response: { 200: z.object({ certificate: z.string().trim(), @@ -54,8 +89,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => commonName: req.body.commonName, keyUsages: req.body.keyUsages, extendedKeyUsages: req.body.extendedKeyUsages, - subjectAlternativeNames: req.body.altNames - ? req.body.altNames + altNames: req.body.subjectAltNames + ? req.body.subjectAltNames .split(", ") .map((name) => name.trim()) .map((name) => { @@ -68,7 +103,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => url: "uri" } as const; return { - type: typeMapping[mappedType.type] as "dns_name" | "ip_address" | "email" | "uri", + type: typeMapping[mappedType.type] as CertSubjectAlternativeNameType, value: mappedType.value }; }) @@ -94,23 +129,16 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateRequest: mappedCertificateRequest }); - const profile = await server.services.certificateProfile.getProfileById({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: req.body.profileId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: profile.projectId, + projectId: data.projectId, event: { type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, certificateId: data.certificateId, - commonName: req.body.commonName || "" + commonName: req.body.commonName || "", + profileName: data.profileName } } }); @@ -128,13 +156,38 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => schema: { hide: false, tags: [ApiDocsTags.PkiCertificates], - body: z.object({ - profileId: z.string().uuid(), - csr: z.string().trim().min(1).max(4096), - ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), - notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional() - }), + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1).max(4096), + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional() + }) + .refine( + (data) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); + }, + { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + } + ) + .refine( + (data) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; + }, + { + message: "notBefore must be earlier than notAfter" + } + ), response: { 200: z.object({ certificate: z.string().trim(), @@ -161,22 +214,16 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined }); - const profile = await server.services.certificateProfile.getProfileById({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: req.body.profileId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: profile.projectId, + projectId: data.projectId, event: { type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, - certificateId: data.certificateId + certificateId: data.certificateId, + profileName: data.profileName, + commonName: req.body.csr || "" } } }); @@ -194,48 +241,73 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => schema: { hide: false, tags: [ApiDocsTags.PkiCertificates], - body: z.object({ - profileId: z.string().uuid(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.enum(["dns", "ip"]), - value: z.string() - }) - ) - .min(1), - ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), - keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), - extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), - notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), - commonName: validateTemplateRegexField.optional(), - signatureAlgorithm: z.string().optional(), - keyAlgorithm: z.string().optional() - }), + body: z + .object({ + profileId: z.string().uuid(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }) + ) + .min(1), + ttl: z.string().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( + (data) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); + }, + { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + } + ) + .refine( + (data) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; + }, + { + message: "notBefore must be earlier than notAfter" + } + ), response: { 200: z.object({ orderId: z.string(), - status: z.enum(["pending", "processing", "valid", "invalid"]), + status: z.nativeEnum(CertificateOrderStatus), subjectAlternativeNames: z.array( z.object({ - type: z.enum(["dns", "ip"]), + type: z.nativeEnum(ACMESANType), value: z.string(), - status: z.enum(["pending", "processing", "valid", "invalid"]) + status: z.nativeEnum(CertificateOrderStatus) }) ), authorizations: z.array( z.object({ identifier: z.object({ - type: z.enum(["dns", "ip"]), + type: z.nativeEnum(ACMESANType), value: z.string() }), - status: z.enum(["pending", "processing", "valid", "invalid"]), + status: z.nativeEnum(CertificateOrderStatus), expires: z.string().optional(), challenges: z.array( z.object({ type: z.string(), - status: z.enum(["pending", "processing", "valid", "invalid"]), + status: z.nativeEnum(CertificateOrderStatus), url: z.string(), token: z.string() }) @@ -256,7 +328,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, profileId: req.body.profileId, certificateOrder: { - subjectAlternativeNames: req.body.subjectAlternativeNames, + altNames: req.body.subjectAlternativeNames, validity: { ttl: req.body.ttl }, @@ -270,23 +342,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => } }); - const profile = await server.services.certificateProfile.getProfileById({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: req.body.profileId - }); - await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: profile.projectId, + projectId: data.projectId, event: { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, orderId: data.orderId, - subjectAlternativeNames: req.body.subjectAlternativeNames.map((san) => `${san.type}:${san.value}`) + profileName: data.profileName } } }); 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..676d89fcd --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts @@ -0,0 +1,153 @@ +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-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-256"); + 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 4b9854abe..5f6ee7678 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -99,29 +99,52 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { } }; -export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm) => { +export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => { // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. const [keyType, hashType] = signatureAlgorithm.split("-"); + 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"; + + return hash; + }; + + const normalizedHash = hashType ? normalizeHashType(hashType) : undefined; + switch (keyType) { case "RSA": return { name: "RSASSA-PKCS1-v1_5", - hash: hashType || "SHA-256", + hash: normalizedHash || "SHA-256", publicExponent: new Uint8Array([1, 0, 1]), modulusLength: keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : 2048 }; case "ECDSA": // eslint-disable-next-line no-case-declarations - const namedCurve = keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 ? "P-384" : "P-256"; + const is384Curve = + keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384"; + // eslint-disable-next-line no-case-declarations + const namedCurve = is384Curve ? "P-384" : "P-256"; return { name: "ECDSA", namedCurve, - hash: hashType || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") + hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") }; default: // Fallback to key algorithm default - return keyAlgorithmToAlgCfg(keyAlgorithm); + return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); } }; 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 1b09cf0fd..383b34c5f 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 @@ -1280,16 +1280,31 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - // Use provided keyAlgorithm if available, otherwise fall back to CA's algorithm 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("RSA"); + const isEcdsaCa = caKeyAlgorithm.startsWith("EC"); + + if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "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.` + }); + } + } + // Determine signing algorithm for certificate signing const signingAlg = signatureAlgorithm - ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) - : keyGenAlg; + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${commonName}`, @@ -1528,7 +1543,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore, notAfter, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1633,7 +1650,26 @@ 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("RSA"); + const isEcdsaCa = caKeyAlgorithm.startsWith("EC"); + + if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "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); 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 06b25e261..d05596c56 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 @@ -150,6 +150,8 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; } | ({ isInternal: false; @@ -165,6 +167,8 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; } & Omit); export type TGetCaCertificateTemplatesDTO = { 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..bd42d05e7 --- /dev/null +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -0,0 +1,186 @@ +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 CertDurationUnit { + DAYS = "days", + MONTHS = "months", + YEARS = "years" +} + +export enum CertSubjectAttributeType { + COMMON_NAME = "common_name" +} + +export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => { + switch (type) { + case CertSubjectAlternativeNameType.DNS_NAME: + return "dns"; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return "ip"; + case CertSubjectAlternativeNameType.EMAIL: + return "email"; + case CertSubjectAlternativeNameType.URI: + return "uri"; + default: + return type; + } +}; + +export const mapLegacySANTypeToStandard = (type: string): CertSubjectAlternativeNameType => { + switch (type) { + case "dns": + case "dns_name": + return CertSubjectAlternativeNameType.DNS_NAME; + case "ip": + case "ip_address": + return CertSubjectAlternativeNameType.IP_ADDRESS; + case "email": + return CertSubjectAlternativeNameType.EMAIL; + case "uri": + case "url": + return CertSubjectAlternativeNameType.URI; + default: + throw new Error(`Unknown SAN type: ${type}`); + } +}; + +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 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); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts index 492fc0543..66aaee4df 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -1,34 +1,39 @@ +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 keyUsageMapping: Record = { - digitalSignature: "digital_signature", - keyEncipherment: "key_encipherment", - nonRepudiation: "non_repudiation", - dataEncipherment: "data_encipherment", - keyAgreement: "key_agreement", - keyCertSign: "key_cert_sign", - cRLSign: "crl_sign", - encipherOnly: "encipher_only", - decipherOnly: "decipher_only" + const mapKeyUsage = (usage: string): string => { + try { + return mapLegacyKeyUsageToStandard(usage); + } catch { + return usage; + } }; - const extendedKeyUsageMapping: Record = { - serverAuth: "server_auth", - clientAuth: "client_auth", - codeSigning: "code_signing", - emailProtection: "email_protection", - timeStamping: "time_stamping", - ocspSigning: "ocsp_signing" + const mapExtendedKeyUsage = (usage: string): string => { + try { + return mapLegacyExtendedKeyUsageToStandard(usage); + } catch { + return usage; + } }; return { ...request, - keyUsages: request.keyUsages?.map((usage: string) => keyUsageMapping[usage] || usage), - extendedKeyUsages: request.extendedKeyUsages?.map((usage: string) => extendedKeyUsageMapping[usage] || usage) + keyUsages: request.keyUsages?.map(mapKeyUsage), + extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage) } as T; }; @@ -51,27 +56,28 @@ export const buildCertificateSubjectFromTemplate = ( ): Record => { const subject: Record = {}; const attributeMap: Record = { - common_name: "commonName", - organization_name: "organization", - organization_unit: "organizationUnit", - locality: "locality", - state: "state", - country: "country", - email: "email", - street_address: "streetAddress", - postal_code: "postalCode" + common_name: "commonName" }; if (!templateAttributes || templateAttributes.length === 0) { - Object.entries(attributeMap).forEach(([templateKey, requestKey]) => { - const value = request[requestKey]; - if (value && typeof value === "string") { - subject[templateKey] = value; - } - }); - return subject; + throw new Error( + "Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints." + ); } + const allowedAttributes = new Set(templateAttributes.map((attr) => attributeMap[attr.type])); + + Object.keys(attributeMap).forEach((templateType) => { + const requestKey = attributeMap[templateType]; + const value = request[requestKey]; + + if (value && !allowedAttributes.has(requestKey)) { + throw new Error( + `Certificate attribute '${requestKey}' is not allowed by the template. Template must define constraints for all requested attributes.` + ); + } + }); + templateAttributes.forEach((attr) => { if (attr.include === "prohibit") { return; @@ -101,12 +107,28 @@ export const buildSubjectAlternativeNamesFromTemplate = ( } if (!templateSans || templateSans.length === 0) { - return request.subjectAlternativeNames.map((san) => san.value).join(","); + if (request.subjectAlternativeNames.length > 0) { + throw new Error( + "Template must define allowed subject alternative names. Cannot issue certificate with SANs when template has no SAN constraints." + ); + } + return ""; } - const allowedSans: string[] = []; + const templateSanTypes = new Set(templateSans.map((san) => san.type)); const prohibitedTypes = new Set(templateSans.filter((san) => san.include === "prohibit").map((san) => san.type)); + request.subjectAlternativeNames.forEach((san) => { + const sanType = san.type === "dns_name" ? "dns_name" : san.type; + if (!templateSanTypes.has(sanType)) { + throw new Error( + `Subject Alternative Name type '${sanType}' is not allowed by the template. Template must define constraints for all requested SAN types.` + ); + } + }); + + const allowedSans: string[] = []; + request.subjectAlternativeNames.forEach((san) => { const sanType = san.type === "dns_name" ? "dns_name" : san.type; if (!prohibitedTypes.has(sanType)) { @@ -116,3 +138,39 @@ export const buildSubjectAlternativeNamesFromTemplate = ( 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.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index 8666ba9d6..c517b927b 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -8,6 +8,7 @@ import { TCertificateAuthorityDALFactory } from "@app/services/certificate-autho import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -56,6 +57,10 @@ export const certificateEstV3ServiceFactory = ({ 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" }); } @@ -141,6 +146,10 @@ export const certificateEstV3ServiceFactory = ({ 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" }); } @@ -237,6 +246,10 @@ export const certificateEstV3ServiceFactory = ({ 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" }); } @@ -280,8 +293,14 @@ export const certificateEstV3ServiceFactory = ({ kmsService }); - const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); + const certificateChain = extractX509CertFromChain(caCertChain); + if (!certificateChain || certificateChain.length === 0) { + throw new BadRequestError({ + message: "Invalid CA certificate chain: unable to extract certificates" + }); + } + const certificates = certificateChain.map((cert) => new x509.X509Certificate(cert)); const caCertificate = new x509.X509Certificate(caCert); return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); }; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index c8aac56de..d2a45f8c9 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -31,42 +31,72 @@ export const createCertificateProfileSchema = z }) .refine( (data) => { - if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { - return false; + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } } - if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { - return false; + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } } return true; }, { - message: "Config must be provided based on enrollment type" + 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() -}); +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() diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 2f72f559c..e8cb0a41f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -122,9 +122,7 @@ describe("CertificateProfileService", () => { create: vi.fn().mockResolvedValue({ id: "api-config-123" }), findById: vi.fn(), updateById: vi.fn(), - deleteById: vi.fn(), findProfilesForAutoRenewal: vi.fn(), - isConfigInUse: vi.fn(), transaction: vi.fn(), find: vi.fn(), findOne: vi.fn(), @@ -136,8 +134,6 @@ describe("CertificateProfileService", () => { create: vi.fn().mockResolvedValue({ id: "est-config-123" }), findById: vi.fn(), updateById: vi.fn(), - deleteById: vi.fn(), - isConfigInUse: vi.fn(), transaction: vi.fn(), find: vi.fn(), findOne: vi.fn(), @@ -158,6 +154,12 @@ describe("CertificateProfileService", () => { 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, @@ -188,7 +190,10 @@ describe("CertificateProfileService", () => { (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); - (mockCertificateProfileDAL.create as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); }); it("should create profile successfully", async () => { @@ -201,16 +206,19 @@ describe("CertificateProfileService", () => { 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" - }); + 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 () => { @@ -318,7 +326,11 @@ describe("CertificateProfileService", () => { beforeEach(() => { (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); - (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ ...sampleProfile, ...updateData }); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...sampleProfile, + ...updateData, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); }); it("should update profile successfully", async () => { @@ -330,7 +342,7 @@ describe("CertificateProfileService", () => { expect(result.slug).toBe("updated-profile"); expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); - expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData); + expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined); }); it("should throw NotFoundError when profile not found", async () => { @@ -720,11 +732,14 @@ describe("CertificateProfileService", () => { }); expect(result.enrollmentType).toBe(EnrollmentType.EST); - expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith({ - disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, - hashedPassphrase: "mocked-hash", - encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64") - }); + expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, + hashedPassphrase: "mocked-hash", + encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64") + }, + undefined + ); }); it("should handle profile slug uniqueness validation", async () => { @@ -772,7 +787,8 @@ describe("CertificateProfileService", () => { (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.create as any).mockResolvedValue({ ...sampleProfile, - apiConfigId: "api-config-123" + apiConfigId: "api-config-123", + enrollmentType: EnrollmentType.API }); const result = await service.createProfile({ @@ -781,10 +797,13 @@ describe("CertificateProfileService", () => { data: autoRenewData }); - expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith({ - autoRenew: true, - autoRenewDays: 7 - }); + expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + autoRenew: true, + autoRenewDays: 7 + }, + undefined + ); expect(result).toBeDefined(); }); }); @@ -1076,7 +1095,8 @@ describe("CertificateProfileService", () => { (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.create as any).mockResolvedValue({ ...sampleProfile, - slug: invalidSlugData.slug + slug: invalidSlugData.slug, + enrollmentType: EnrollmentType.API }); const result = await service.createProfile({ diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 6937b9bcf..cd50187ca 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -49,70 +49,6 @@ const convertDalToService = (dalResult: Record): TCertificatePr } as TCertificateProfile; }; -const validateEnrollmentConfig = async (data: { - enrollmentType: EnrollmentType; - estConfig?: TEstConfigData | null; - apiConfig?: TApiConfigData | null; -}): Promise => { - if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - throw new ForbiddenRequestError({ - message: "EST enrollment type requires EST configuration" - }); - } - if (data.apiConfig) { - throw new ForbiddenRequestError({ - message: "EST enrollment type cannot have API configuration" - }); - } - } else if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - throw new ForbiddenRequestError({ - message: "API enrollment type requires API configuration" - }); - } - if (data.estConfig) { - throw new ForbiddenRequestError({ - message: "API enrollment type cannot have EST configuration" - }); - } - } -}; - -const validateEnrollmentConfigForUpdate = async (data: { - enrollmentType: EnrollmentType; - estConfigId?: string | null; - apiConfigId?: string | null; -}): Promise => { - if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfigId) { - throw new ForbiddenRequestError({ - message: "EST enrollment type requires EST configuration ID" - }); - } - if (data.apiConfigId) { - throw new ForbiddenRequestError({ - message: "EST enrollment type cannot have API configuration ID" - }); - } - } else if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfigId) { - throw new ForbiddenRequestError({ - message: "API enrollment type requires API configuration ID" - }); - } - if (data.estConfigId) { - throw new ForbiddenRequestError({ - message: "API enrollment type cannot have EST configuration ID" - }); - } - } -}; - -const hasEnrollmentConfigChanges = (data: TCertificateProfileUpdate): boolean => { - return !!(data.enrollmentType || data.estConfigId || data.apiConfigId); -}; - export const certificateProfileServiceFactory = ({ certificateProfileDAL, certificateTemplateV2DAL, @@ -169,43 +105,61 @@ export const certificateProfileServiceFactory = ({ }); } - // Validate enrollment type configuration - await validateEnrollmentConfig({ - enrollmentType: data.enrollmentType, - estConfig: data.estConfig, - apiConfig: data.apiConfig - }); - - // Create enrollment configs based on type - 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); - - const estConfig = await estEnrollmentConfigDAL.create({ - disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, - hashedPassphrase, - encryptedCaChain: Buffer.from(data.estConfig.encryptedCaChain, "base64") + // Validate enrollment configuration requirements + if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { + throw new ForbiddenRequestError({ + message: "EST enrollment requires EST configuration" }); - estConfigId = estConfig.id; - } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { - const apiConfig = await apiEnrollmentConfigDAL.create({ - autoRenew: data.apiConfig.autoRenew, - autoRenewDays: data.apiConfig.autoRenewDays + } + if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { + throw new ForbiddenRequestError({ + message: "API enrollment requires API configuration" }); - apiConfigId = apiConfig.id; } - // Create the profile with the created config IDs - const { estConfig, apiConfig, ...profileData } = data; - const profile = await certificateProfileDAL.create({ - ...profileData, - projectId, - estConfigId, - apiConfigId + // 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); + + const estConfig = await estEnrollmentConfigDAL.create( + { + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, + hashedPassphrase, + encryptedCaChain: Buffer.from(data.estConfig.encryptedCaChain, "base64") + }, + 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); @@ -268,37 +222,40 @@ export const certificateProfileServiceFactory = ({ } } - if (hasEnrollmentConfigChanges(data)) { - const mergedData = { ...existingProfile, ...data }; - await validateEnrollmentConfigForUpdate({ - enrollmentType: mergedData.enrollmentType as EnrollmentType, - estConfigId: mergedData.estConfigId, - apiConfigId: mergedData.apiConfigId - }); - } - const { estConfig, apiConfig, ...profileUpdateData } = data; - if (estConfig && existingProfile.estConfigId) { - await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, { - disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation, - ...(estConfig.passphrase && { - hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS) - }), - ...(estConfig.caChain && { - encryptedCaChain: Buffer.from(estConfig.caChain, "base64") - }) - }); - } + const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { + if (estConfig && existingProfile.estConfigId) { + await estEnrollmentConfigDAL.updateById( + existingProfile.estConfigId, + { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation, + ...(estConfig.passphrase && { + hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS) + }), + ...(estConfig.caChain && { + encryptedCaChain: Buffer.from(estConfig.caChain, "base64") + }) + }, + tx + ); + } - if (apiConfig && existingProfile.apiConfigId) { - await apiEnrollmentConfigDAL.updateById(existingProfile.apiConfigId, { - autoRenew: apiConfig.autoRenew, - autoRenewDays: apiConfig.autoRenewDays - }); - } + 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; + }); - const updatedProfile = await certificateProfileDAL.updateById(profileId, profileUpdateData); return convertDalToService(updatedProfile); }; 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 index 5307308b8..eb4f5b794 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -14,6 +14,10 @@ import { export type TCertificateTemplateV2DALFactory = ReturnType; +interface CountResult { + count: string; +} + export const certificateTemplateV2DALFactory = (db: TDbClient) => { const certificateTemplateV2Orm = ormify(db, TableName.CertificateTemplateV2); @@ -199,12 +203,33 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { .count("*") .first(); - return parseInt(profileCount || "0", 10) > 0; + 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.CertificateProfile) + .select("id", "slug", "description") + .where({ certificateTemplateId: templateId }); + + return profiles; + } catch (error) { + throw new DatabaseError({ error, name: "Get profiles using certificate template v2" }); + } + }; + return { ...certificateTemplateV2Orm, create, @@ -214,6 +239,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { findByProjectId, countByProjectId, findBySlugAndProjectId, - isTemplateInUse + 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 index cfbf516f6..b724b8469 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -1,13 +1,22 @@ -import RE2 from "re2"; import { z } from "zod"; -const attributeTypeSchema = z.enum(["common_name"]); +import { slugSchema } from "@app/server/lib/schemas"; +import { + CertDurationUnit, + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; -const includeTypeSchema = z.enum(["mandatory", "optional", "prohibit"]); +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); -const sanTypeSchema = z.enum(["dns_name", "ip_address", "email", "uri"]); +const includeTypeSchema = z.nativeEnum(CertIncludeType); -const durationUnitSchema = z.enum(["days", "months", "years"]); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const durationUnitSchema = z.nativeEnum(CertDurationUnit); export const templateV2AttributeSchema = z .object({ @@ -17,32 +26,43 @@ export const templateV2AttributeSchema = z }) .refine( (data) => { + if (data.type === "common_name" && data.value && data.value.length > 1) { + return false; + } if (data.include === "mandatory" && (!data.value || data.value.length > 1)) { return false; } return true; }, { - message: "Mandatory attributes can only have one value or no value (empty)" + message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)" } ); export const templateV2KeyUsagesSchema = z.object({ - requiredUsages: z.object({ - all: z.array(z.string()) - }), - optionalUsages: z.object({ - all: z.array(z.string()) - }) + requiredUsages: z + .object({ + all: z.array(z.nativeEnum(CertKeyUsageType)) + }) + .optional(), + optionalUsages: z + .object({ + all: z.array(z.nativeEnum(CertKeyUsageType)) + }) + .optional() }); export const templateV2ExtendedKeyUsagesSchema = z.object({ - requiredUsages: z.object({ - all: z.array(z.string()) - }), - optionalUsages: z.object({ - all: z.array(z.string()) - }) + requiredUsages: z + .object({ + all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) + }) + .optional(), + optionalUsages: z + .object({ + all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) + }) + .optional() }); export const templateV2SanSchema = z @@ -76,26 +96,30 @@ export const templateV2ValiditySchema = z.object({ .optional() }); -export const templateV2SignatureAlgorithmSchema = z.object({ - allowedAlgorithms: z.array(z.string()).min(1), - defaultAlgorithm: z.string() -}); +export const templateV2SignatureAlgorithmSchema = z + .object({ + allowedAlgorithms: z.array(z.string()).min(1), + defaultAlgorithm: z.string() + }) + .refine((data) => data.allowedAlgorithms.includes(data.defaultAlgorithm), { + message: "Default signature algorithm must be included in the allowed algorithms list" + }); -export const templateV2KeyAlgorithmSchema = z.object({ - allowedKeyTypes: z.array(z.string()).min(1), - defaultKeyType: z.string() -}); +export const templateV2KeyAlgorithmSchema = z + .object({ + allowedKeyTypes: z.array(z.string()).min(1), + defaultKeyType: z.string() + }) + .refine((data) => data.allowedKeyTypes.includes(data.defaultKeyType), { + message: "Default key algorithm must be included in the allowed key types list" + }); export const createCertificateTemplateV2Schema = z.object({ projectId: z.string().min(1), - slug: z - .string() - .min(1) - .max(255) - .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + slug: slugSchema({ min: 1, max: 255 }), description: z.string().max(1000).optional(), - attributes: z.array(templateV2AttributeSchema).optional(), - keyUsages: templateV2KeyUsagesSchema.optional(), + attributes: z.array(templateV2AttributeSchema).min(1), + keyUsages: templateV2KeyUsagesSchema, extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), subjectAlternativeNames: z.array(templateV2SanSchema).optional(), validity: templateV2ValiditySchema.optional(), @@ -104,12 +128,7 @@ export const createCertificateTemplateV2Schema = z.object({ }); export const updateCertificateTemplateV2Schema = 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(), + slug: slugSchema({ min: 1, max: 255 }).optional(), description: z.string().max(1000).optional(), attributes: z.array(templateV2AttributeSchema).optional(), keyUsages: templateV2KeyUsagesSchema.optional(), @@ -126,7 +145,7 @@ export const getCertificateTemplateV2ByIdSchema = z.object({ export const getCertificateTemplateV2BySlugSchema = z.object({ projectId: z.string().min(1), - slug: z.string().min(1) + slug: slugSchema() }); export const listCertificateTemplatesV2Schema = z.object({ @@ -142,8 +161,8 @@ export const deleteCertificateTemplateV2Schema = z.object({ export const certificateRequestSchema = z.object({ commonName: z.string().optional(), - keyUsages: z.array(z.string()).optional(), - extendedKeyUsages: z.array(z.string()).optional(), + keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), subjectAlternativeNames: z .array( z.object({ 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 index f99c8eed9..333c13fbf 100644 --- 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 @@ -11,6 +11,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + CertDurationUnit, + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "../certificate-common/certificate-constants"; import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; import { certificateTemplateV2ServiceFactory, @@ -35,6 +43,7 @@ describe("CertificateTemplateV2Service", () => { findByProjectId: vi.fn(), countByProjectId: vi.fn(), isTemplateInUse: vi.fn(), + getProfilesUsingTemplate: vi.fn(), findByNameAndProjectId: vi.fn(), transaction: vi.fn(), find: vi.fn(), @@ -58,38 +67,38 @@ describe("CertificateTemplateV2Service", () => { const samplePolicy: TTemplateV2Policy = { attributes: [ { - type: "common_name", - include: "mandatory", + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY, value: ["example.com"] } ], keyUsages: { - requiredUsages: { all: ["digital_signature", "key_encipherment"] }, - optionalUsages: { all: ["data_encipherment"] } + requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] }, + optionalUsages: { all: [CertKeyUsageType.DATA_ENCIPHERMENT] } }, extendedKeyUsages: { - requiredUsages: { all: ["server_auth"] }, - optionalUsages: { all: ["client_auth"] } + requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, + optionalUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH] } }, subjectAlternativeNames: [ { - type: "dns_name", - include: "optional", + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL, value: ["example.com", "*.example.com"] }, { - type: "ip_address", - include: "mandatory", + type: CertSubjectAlternativeNameType.IP_ADDRESS, + include: CertIncludeType.MANDATORY, value: ["192.168.1.1"] } ], validity: { - maxDuration: { value: 90, unit: "days" }, - minDuration: { value: 1, unit: "days" } + maxDuration: { value: 90, unit: CertDurationUnit.DAYS }, + minDuration: { value: 1, unit: CertDurationUnit.DAYS } }, signatureAlgorithm: { - allowedAlgorithms: ["RSA-SHA256", "ECDSA-SHA256"], - defaultAlgorithm: "RSA-SHA256" + allowedAlgorithms: ["SHA256-RSA", "SHA256-ECDSA"], + defaultAlgorithm: "SHA256-RSA" }, keyAlgorithm: { allowedKeyTypes: ["RSA-2048", "RSA-4096", "ECDSA-P256"], @@ -172,71 +181,15 @@ describe("CertificateTemplateV2Service", () => { expect(result).toEqual(sampleTemplate); }); - it("should throw error for invalid policy - missing attributes", async () => { - const invalidData = { - ...createData, - attributes: undefined as any - }; - - await expect( - service.createTemplateV2({ - ...mockActor, - projectId: "project-123", - data: invalidData - }) - ).rejects.toThrow("Template policy must include attributes array"); - }); - - it("should throw error for invalid policy - missing key usages", async () => { - const invalidData = { - ...createData, - keyUsages: undefined as any - }; - - await expect( - service.createTemplateV2({ - ...mockActor, - projectId: "project-123", - data: invalidData - }) - ).rejects.toThrow("Template policy must include valid key usages configuration"); - }); - - it("should throw error when default signature algorithm not in allowed list", async () => { - const invalidData = { - ...createData, - signatureAlgorithm: { - allowedAlgorithms: ["RSA-SHA256"], - defaultAlgorithm: "ECDSA-SHA256" - } - }; - - await expect( - service.createTemplateV2({ - ...mockActor, - projectId: "project-123", - data: invalidData - }) - ).rejects.toThrow("Default signature algorithm must be in allowed algorithms list"); - }); - - it("should throw error when default key algorithm not in allowed list", async () => { - const invalidData = { - ...createData, - keyAlgorithm: { - allowedKeyTypes: ["RSA-2048"], - defaultKeyType: "RSA-4096" - } - }; - - await expect( - service.createTemplateV2({ - ...mockActor, - projectId: "project-123", - data: invalidData - }) - ).rejects.toThrow("Default key algorithm must be in allowed key types list"); - }); + // NOTE: The following validation tests have been removed because validation + // was moved from service level to schema level as part of the validation refactoring. + // Schema-level validation should be tested separately at the router/schema level. + // + // 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", () => { @@ -270,24 +223,7 @@ describe("CertificateTemplateV2Service", () => { ).rejects.toThrow(NotFoundError); }); - it("should validate policy when updating policy fields", async () => { - const invalidPolicyUpdate = { - signatureAlgorithm: { - allowedAlgorithms: ["RSA-SHA256"], - defaultAlgorithm: "INVALID-ALGO" - } - }; - - mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); - - await expect( - service.updateTemplateV2({ - ...mockActor, - templateId: "template-123", - data: invalidPolicyUpdate - }) - ).rejects.toThrow("Default signature algorithm must be in allowed algorithms list"); - }); + // NOTE: Policy validation test removed as validation moved to schema level }); describe("getTemplateV2ById", () => { @@ -394,8 +330,14 @@ describe("CertificateTemplateV2Service", () => { }); 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({ @@ -403,6 +345,8 @@ describe("CertificateTemplateV2Service", () => { templateId: "template-123" }) ).rejects.toThrow(ForbiddenRequestError); + + expect(mockCertificateTemplateV2DAL.getProfilesUsingTemplate).toHaveBeenCalledWith("template-123"); expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled(); }); }); @@ -410,16 +354,16 @@ describe("CertificateTemplateV2Service", () => { describe("validateCertificateRequest", () => { const validRequest: TCertificateRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], subjectAlternativeNames: [ - { type: "dns_name", value: "example.com" }, - { type: "dns_name", value: "*.example.com" }, - { type: "ip_address", value: "192.168.1.1" } + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }, + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "*.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.1" } ], validity: { ttl: "30d" }, signatureAlgorithm: "RSA-SHA256", - keyAlgorithm: "RSA-2048" + keyAlgorithm: "RSA_2048" }; beforeEach(() => { @@ -461,7 +405,7 @@ describe("CertificateTemplateV2Service", () => { }); it("should detect missing required key usages", async () => { - const invalidRequest = { ...validRequest, keyUsages: ["digital_signature"] }; + const invalidRequest = { ...validRequest, keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE] }; const result = await service.validateCertificateRequest("template-123", invalidRequest); @@ -472,7 +416,7 @@ describe("CertificateTemplateV2Service", () => { it("should detect invalid key usages", async () => { const invalidRequest = { ...validRequest, - keyUsages: ["digital_signature", "key_encipherment", "invalid_usage"] + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, "invalid_usage"] as any }; const result = await service.validateCertificateRequest("template-123", invalidRequest); @@ -493,7 +437,7 @@ describe("CertificateTemplateV2Service", () => { it("should detect invalid extended key usages", async () => { const invalidRequest = { ...validRequest, - extendedKeyUsages: ["server_auth", "invalid_eku"] + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, "invalid_eku"] as any }; const result = await service.validateCertificateRequest("template-123", invalidRequest); @@ -515,8 +459,8 @@ describe("CertificateTemplateV2Service", () => { const invalidRequest: TCertificateRequest = { ...validRequest, subjectAlternativeNames: [ - { type: "dns_name", value: "forbidden.com" }, - { type: "ip_address", value: "192.168.1.1" } + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.1" } ] }; @@ -559,8 +503,8 @@ describe("CertificateTemplateV2Service", () => { const templateWithMinDuration = { ...sampleTemplate, validity: { - maxDuration: { value: 90, unit: "days" as const }, - minDuration: { value: 7, unit: "days" as const } + maxDuration: { value: 90, unit: CertDurationUnit.DAYS as const }, + minDuration: { value: 7, unit: CertDurationUnit.DAYS as const } } }; @@ -601,8 +545,12 @@ describe("CertificateTemplateV2Service", () => { it("should allow optional key usages and extended key usages", async () => { const requestWithOptionalUsages = { ...validRequest, - keyUsages: ["digital_signature", "key_encipherment", "data_encipherment"], - extendedKeyUsages: ["server_auth", "client_auth"] + 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); @@ -614,20 +562,28 @@ describe("CertificateTemplateV2Service", () => { const templateWithOptionalUsages = { ...sampleTemplate, keyUsages: { - requiredUsages: { all: ["digital_signature", "non_repudiation", "key_agreement"] }, - optionalUsages: { all: ["crl_sign", "decipher_only"] } + requiredUsages: { + all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] + }, + optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } }, extendedKeyUsages: { - requiredUsages: { all: ["client_auth", "code_signing"] }, - optionalUsages: { all: ["server_auth", "ocsp_signing"] } + requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } } }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); const requestWithCamelCaseUsages = { ...validRequest, - keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"], - extendedKeyUsages: ["client_auth", "code_signing"] + 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); @@ -640,8 +596,8 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "optional" as const, + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["*.example.com", "*.test.com"] } ] @@ -662,8 +618,8 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "optional" as const, + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["*.example.com"] } ] @@ -687,8 +643,8 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "mandatory" as const, + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, value: undefined } ] @@ -710,8 +666,8 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, subjectAlternativeNames: [ { - type: "email" as const, - include: "prohibit" as const + type: CertSubjectAlternativeNameType.EMAIL as const, + include: CertIncludeType.PROHIBIT as const } ] }; @@ -719,7 +675,7 @@ describe("CertificateTemplateV2Service", () => { const requestWithProhibitedSan = { ...validRequest, - subjectAlternativeNames: [{ type: "email" as const, value: "test@example.com" }] + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }] }; const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); @@ -733,21 +689,21 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "mandatory" as const + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const } ], keyUsages: { - requiredUsages: { all: ["digital_signature"] }, + requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE] }, optionalUsages: { all: [] } }, extendedKeyUsages: { requiredUsages: { all: [] }, - optionalUsages: { all: ["server_auth"] } + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] } }, subjectAlternativeNames: [], validity: { - maxDuration: { value: 30, unit: "days" as const } + maxDuration: { value: 30, unit: CertDurationUnit.DAYS as const } }, signatureAlgorithm: undefined, keyAlgorithm: undefined @@ -756,7 +712,7 @@ describe("CertificateTemplateV2Service", () => { const minimalRequest = { commonName: "example.com", - keyUsages: ["digital_signature"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], validity: { ttl: "15d" } }; @@ -769,30 +725,30 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "optional" as const + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL as const }, { type: "organization_name", - include: "optional" as const + include: CertIncludeType.OPTIONAL as const }, { type: "locality", - include: "optional" as const + include: CertIncludeType.OPTIONAL as const } ], keyUsages: { requiredUsages: { all: [] }, - optionalUsages: { all: ["digital_signature", "key_encipherment"] } + optionalUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] } }, extendedKeyUsages: { requiredUsages: { all: [] }, - optionalUsages: { all: ["server_auth", "client_auth"] } + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] } }, subjectAlternativeNames: [ { - type: "dns_name", - include: "optional" as const + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const } ] }; @@ -811,27 +767,27 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "mandatory" as const, + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, value: ["example.com"] } ], keyUsages: { - requiredUsages: { all: ["digital_signature"] }, + requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE] }, optionalUsages: { all: [] } }, extendedKeyUsages: { - requiredUsages: { all: ["server_auth"] }, + requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, optionalUsages: { all: [] } }, subjectAlternativeNames: [ { - type: "email", - include: "prohibit" as const + type: CertSubjectAlternativeNameType.EMAIL, + include: CertIncludeType.PROHIBIT as const }, { - type: "uri", - include: "prohibit" as const + type: CertSubjectAlternativeNameType.URI, + include: CertIncludeType.PROHIBIT as const } ] }; @@ -839,11 +795,11 @@ describe("CertificateTemplateV2Service", () => { const requestWithProhibited = { commonName: "example.com", - keyUsages: ["digital_signature"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], subjectAlternativeNames: [ - { type: "email" as const, value: "test@example.com" }, - { type: "uri" as const, value: "https://example.com" } + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }, + { type: CertSubjectAlternativeNameType.URI as const, value: "https://example.com" } ], validity: { ttl: "30d" } }; @@ -859,15 +815,15 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, attributes: [ { - type: "common_name", - include: "mandatory" as const, + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, value: ["example.com", "test.com"] } ], subjectAlternativeNames: [ { - type: "dns_name", - include: "optional" as const, + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["example.com", "*.example.com"] } ] @@ -876,8 +832,8 @@ describe("CertificateTemplateV2Service", () => { const validConstrainedRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: "30d" } }; @@ -886,8 +842,8 @@ describe("CertificateTemplateV2Service", () => { const invalidConstrainedRequest = { commonName: "forbidden.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: "30d" } }; @@ -901,18 +857,18 @@ describe("CertificateTemplateV2Service", () => { ...sampleTemplate, subjectAlternativeNames: [ { - type: "dns_name", - include: "mandatory" as const, + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.MANDATORY as const, value: ["example.com", "test.com"] }, { - type: "ip_address", - include: "optional" as const, + type: CertSubjectAlternativeNameType.IP_ADDRESS, + include: CertIncludeType.OPTIONAL as const, value: ["192.168.1.1", "10.0.0.1"] }, { - type: "email", - include: "mandatory" as const, + type: CertSubjectAlternativeNameType.EMAIL, + include: CertIncludeType.MANDATORY as const, value: ["admin@example.com", "test@example.com"] } ] @@ -921,12 +877,12 @@ describe("CertificateTemplateV2Service", () => { const validSanRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], subjectAlternativeNames: [ - { type: "dns_name" as const, value: "example.com" }, - { type: "ip_address" as const, value: "192.168.1.1" }, - { type: "email" as const, value: "admin@example.com" } + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }, + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } ], validity: { ttl: "30d" } }; @@ -936,9 +892,9 @@ describe("CertificateTemplateV2Service", () => { const missingSanRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], - subjectAlternativeNames: [{ type: "dns_name" as const, value: "example.com" }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "example.com" }], validity: { ttl: "30d" } }; @@ -951,17 +907,17 @@ describe("CertificateTemplateV2Service", () => { const keyUsageTemplate = { ...sampleTemplate, keyUsages: { - requiredUsages: { all: ["digital_signature", "key_encipherment"] }, - optionalUsages: { all: ["data_encipherment", "key_agreement"] } + requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] }, + optionalUsages: { all: [CertKeyUsageType.DATA_ENCIPHERMENT, CertKeyUsageType.KEY_AGREEMENT] } }, extendedKeyUsages: { - requiredUsages: { all: ["server_auth"] }, - optionalUsages: { all: ["client_auth", "email_protection"] } + requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, + optionalUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.EMAIL_PROTECTION] } }, subjectAlternativeNames: [ { - type: "dns_name", - include: "optional" as const, + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["example.com", "*.example.com"] } ] @@ -970,8 +926,8 @@ describe("CertificateTemplateV2Service", () => { const minimalUsageRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: "30d" } }; @@ -980,8 +936,12 @@ describe("CertificateTemplateV2Service", () => { const extendedUsageRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment", "data_encipherment"], - extendedKeyUsages: ["server_auth", "client_auth"], + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH], validity: { ttl: "30d" } }; @@ -990,8 +950,8 @@ describe("CertificateTemplateV2Service", () => { const forbiddenUsageRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment", "crl_sign"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, CertKeyUsageType.CRL_SIGN], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: "30d" } }; @@ -1004,8 +964,8 @@ describe("CertificateTemplateV2Service", () => { const algorithmTemplate = { ...sampleTemplate, signatureAlgorithm: { - allowedAlgorithms: ["RSA-SHA256", "RSA-SHA512"], - defaultAlgorithm: "RSA-SHA256" + allowedAlgorithms: ["SHA256-RSA", "SHA512-RSA"], + defaultAlgorithm: "SHA256-RSA" }, keyAlgorithm: { allowedKeyTypes: ["RSA-2048", "RSA-4096"], @@ -1013,8 +973,8 @@ describe("CertificateTemplateV2Service", () => { }, subjectAlternativeNames: [ { - type: "dns_name", - include: "optional" as const, + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["example.com", "*.example.com"] } ] @@ -1023,10 +983,10 @@ describe("CertificateTemplateV2Service", () => { const validAlgoRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], signatureAlgorithm: "RSA-SHA512", - keyAlgorithm: "RSA-4096", + keyAlgorithm: "RSA_4096", validity: { ttl: "30d" } }; @@ -1035,10 +995,10 @@ describe("CertificateTemplateV2Service", () => { const invalidSigRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], signatureAlgorithm: "ECDSA-SHA256", - keyAlgorithm: "RSA-2048", + keyAlgorithm: "RSA_2048", validity: { ttl: "30d" } }; @@ -1050,29 +1010,29 @@ describe("CertificateTemplateV2Service", () => { const invalidKeyRequest = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], signatureAlgorithm: "RSA-SHA256", - keyAlgorithm: "ECDSA-P256", + 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 'ECDSA-P256' is not allowed by template policy"); + 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: { - maxDuration: { value: 365, unit: "days" as const }, - minDuration: { value: 1, unit: "days" as const } + maxDuration: { value: 365, unit: CertDurationUnit.DAYS as const }, + minDuration: { value: 1, unit: CertDurationUnit.DAYS as const } }, subjectAlternativeNames: [ { - type: "dns_name", - include: "optional" as const, + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, value: ["example.com", "*.example.com"] } ] @@ -1093,8 +1053,8 @@ describe("CertificateTemplateV2Service", () => { for (const testCase of testCases) { const request = { commonName: "example.com", - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: testCase.ttl } }; @@ -1108,17 +1068,240 @@ describe("CertificateTemplateV2Service", () => { }); }); + describe("unlisted field validation", () => { + it("should reject requests with unlisted subject attributes", async () => { + const templateWithLimitedAttributes = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const + } + ] + }; + 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, + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const + } + ] + }; + 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, + signatureAlgorithm: undefined, + keyAlgorithm: 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, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, + value: ["example.com", "*.example.com"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, + value: ["example.com", "*.example.com"] + } + ] + }; + 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("common_name is mandatory but not provided in request"); + }); + }); + + describe("improved wildcard pattern validation", () => { + it("should handle complex wildcard patterns", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, + value: ["*.api.example.com", "service-*.internal.com", "exact-match.com"] + } + ], + subjectAlternativeNames: [] // Remove SAN requirements for this test + }; + 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"))).toBe(true); + } + } + }); + + it("should handle special regex characters in wildcard patterns", async () => { + const specialCharTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.MANDATORY as const, + value: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + } + ], + subjectAlternativeNames: [] // Remove SAN requirements for this test + }; + 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, signatureAlgorithm: { - allowedAlgorithms: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], - defaultAlgorithm: "RSA-SHA256" + allowedAlgorithms: ["SHA256-RSA", "SHA512-RSA", "SHA256-ECDSA"], + defaultAlgorithm: "SHA256-RSA" }, keyAlgorithm: { - allowedKeyTypes: ["RSA_2048", "RSA_4096", "ECDSA_P256"], - defaultKeyType: "RSA_2048" + allowedKeyTypes: ["RSA-2048", "RSA-4096", "ECDSA-P256"], + defaultKeyType: "RSA-2048" } }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); @@ -1138,7 +1321,7 @@ describe("CertificateTemplateV2Service", () => { }, { signatureAlgorithm: "ECDSA-SHA256", - keyAlgorithm: "ECDSA_P256", + keyAlgorithm: "EC_prime256v1", shouldBeValid: true, description: "ECDSA algorithms" }, @@ -1166,9 +1349,11 @@ describe("CertificateTemplateV2Service", () => { const request = { commonName: "example.com", validity: { ttl: "30d" }, - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], - subjectAlternativeNames: [{ type: "ip_address" as const, value: "192.168.1.1" }], + 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 }; @@ -1186,7 +1371,7 @@ describe("CertificateTemplateV2Service", () => { } }); - it("should validate when no algorithm constraints are defined", async () => { + it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { const templateWithoutAlgorithms = { ...sampleTemplate, signatureAlgorithm: undefined, @@ -1197,17 +1382,120 @@ describe("CertificateTemplateV2Service", () => { const request = { commonName: "example.com", validity: { ttl: "30d" }, - keyUsages: ["digital_signature", "key_encipherment"], - extendedKeyUsages: ["server_auth"], - subjectAlternativeNames: [{ type: "ip_address" as const, value: "192.168.1.1" }], - signatureAlgorithm: "RSA-SHA256", - keyAlgorithm: "RSA_2048" + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }] + // No algorithms specified - should be valid }; 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, + signatureAlgorithm: undefined, + keyAlgorithm: 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, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL as const, + value: ["*.infisical.com"] + }, + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL as const, + value: ["*.infisical2.com"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, + value: ["*.infisical.com"] + }, + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertIncludeType.OPTIONAL as const, + value: ["*.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" + ); + }); }); }); }); 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 index 727641ea7..fa09215e6 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -12,13 +12,13 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CertIncludeType, CertSubjectAttributeType } from "../certificate-common/certificate-constants"; import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; import { TCertificateRequest, TCertificateTemplateV2, TCertificateTemplateV2Insert, TCertificateTemplateV2Update, - TTemplateV2Policy, TTemplateValidationResult } from "./certificate-template-v2-types"; @@ -70,8 +70,41 @@ export const certificateTemplateV2ServiceFactory = ({ } }; - const getRequestAttributeValue = (request: TCertificateRequest, attrType: string): string | undefined => { + const validateSubjectAttributePolicy = (attributes: Array<{ type: string; include: string; value?: string[] }>) => { + if (!attributes || attributes.length === 0) return; + + const attributesByType = attributes.reduce( + (acc, attr) => { + if (!acc[attr.type]) acc[attr.type] = []; + acc[attr.type].push(attr); + return acc; + }, + {} as Record + ); + + for (const [type, attrs] of Object.entries(attributesByType)) { + const mandatoryAttrs = attrs.filter((attr) => attr.include === CertIncludeType.MANDATORY); + + if (mandatoryAttrs.length > 1) { + throw new ForbiddenRequestError({ + message: `Multiple mandatory values found for subject attribute type '${type}'. Only one mandatory value is allowed per attribute type.` + }); + } + + if (mandatoryAttrs.length === 1 && attrs.length > 1) { + throw new ForbiddenRequestError({ + message: `When a mandatory value exists for subject attribute type '${type}', no other values (optional or forbidden) are allowed for that attribute type.` + }); + } + } + }; + + const getRequestAttributeValue = ( + request: TCertificateRequest, + attrType: CertSubjectAttributeType | string + ): string | undefined => { switch (attrType) { + case CertSubjectAttributeType.COMMON_NAME: case "common_name": return request.commonName; default: @@ -79,44 +112,6 @@ export const certificateTemplateV2ServiceFactory = ({ } }; - const validateTemplatePolicy = (policy: Partial): void => { - if (!policy) { - throw new Error("Template policy is required"); - } - - if (!policy.attributes || policy.attributes.length === 0) { - throw new Error("Template policy must include attributes array"); - } - - if (!policy.keyUsages || !policy.keyUsages.requiredUsages || !policy.keyUsages.optionalUsages) { - throw new Error("Template policy must include valid key usages configuration"); - } - - if (policy.signatureAlgorithm) { - if (!policy.signatureAlgorithm.allowedAlgorithms.includes(policy.signatureAlgorithm.defaultAlgorithm)) { - throw new Error("Default signature algorithm must be in allowed algorithms list"); - } - } - - if (policy.keyAlgorithm) { - if (!policy.keyAlgorithm.allowedKeyTypes.includes(policy.keyAlgorithm.defaultKeyType)) { - throw new Error("Default key algorithm must be in allowed key types list"); - } - } - }; - - const hasAnyPolicyField = (data: TCertificateTemplateV2Update): boolean => { - return !!( - data.attributes || - data.keyUsages || - data.extendedKeyUsages || - data.subjectAlternativeNames || - data.validity || - data.signatureAlgorithm || - data.keyAlgorithm - ); - }; - const generateTemplateSlug = (baseSlug?: string): string => { if (baseSlug) { return slugify(baseSlug); @@ -139,177 +134,274 @@ export const certificateTemplateV2ServiceFactory = ({ return randomSlug; }; + const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); + }; + + const createWildcardRegex = (pattern: string): RegExp => { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + const regexPattern = escaped.replace(/\*/g, ".*"); + 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-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1" + }; + return mapping[templateFormat] || templateFormat; + }; + + 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 { + 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[] = []; + const templateAttributeTypes = new Set(template.attributes?.map((attr) => attr.type) || []); + + const attributePoliciesByType = new Map(); template.attributes?.forEach((attrPolicy) => { - const requestValue = getRequestAttributeValue(request, attrPolicy.type); - - if (attrPolicy.include === "mandatory") { - if (!requestValue) { - errors.push(`${attrPolicy.type} is mandatory but not provided in request`); - } else if (attrPolicy.value && attrPolicy.value.length > 0) { - // Check if the request value matches any allowed pattern - const hasWildcards = attrPolicy.value.some((val) => val.includes("*")); - const isValidValue = attrPolicy.value.some((allowedValue) => { - if (allowedValue.includes("*")) { - // Handle wildcard patterns - const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); - const regex = new RE2(`^${pattern}$`); - return regex.test(requestValue); - } - return allowedValue === requestValue; - }); - if (!isValidValue) { - if (hasWildcards) { - errors.push( - `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}` - ); - } else { - errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`); - } - } - } - } - - if (attrPolicy.include === "prohibit" && requestValue) { - errors.push(`${attrPolicy.type} is prohibited by template policy`); - } - - if (attrPolicy.include === "optional" && requestValue && attrPolicy.value && attrPolicy.value.length > 0) { - const hasWildcards = attrPolicy.value.some((val) => val.includes("*")); - const isValidValue = attrPolicy.value.some((allowedValue) => { - if (allowedValue.includes("*")) { - // Handle wildcard patterns - escape dots and replace * with .* - const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); - const regex = new RE2(`^${pattern}$`); - return regex.test(requestValue); - } - return allowedValue === requestValue; - }); - if (!isValidValue) { - if (hasWildcards) { - errors.push( - `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}` - ); - } else { - errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`); - } - } - } + const existing = attributePoliciesByType.get(attrPolicy.type) || []; + attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]); }); - if (template.keyUsages) { - const missingRequired = template.keyUsages.requiredUsages.all.filter( - (usage) => !request.keyUsages?.includes(usage) - ); - if (missingRequired.length > 0) { - errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + for (const [attrType, policies] of attributePoliciesByType) { + const requestValue = getRequestAttributeValue(request, attrType); + + const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY); + const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT); + + if (hasProhibit && requestValue) { + errors.push(`${attrType} is prohibited by template policy`); + // eslint-disable-next-line no-continue + continue; } - if (request.keyUsages) { - const allAllowedUsages = [...template.keyUsages.requiredUsages.all, ...template.keyUsages.optionalUsages.all]; - const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); - if (invalidUsages.length > 0) { - errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); + if (hasMandatory && !requestValue) { + errors.push(`${attrType} is mandatory but not provided in request`); + // eslint-disable-next-line no-continue + continue; + } + + if (requestValue) { + const policiesWithValues = policies.filter( + (p) => + p.value && + p.value.length > 0 && + (p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL) + ); + + if (policiesWithValues.length > 0) { + const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []); + + const validation = validateValueAgainstConstraints(requestValue, allAllowedValues, attrType); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } } } } + const requestAttributeTypes: CertSubjectAttributeType[] = []; + if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME); + + for (const requestAttrType of requestAttributeTypes) { + if (!templateAttributeTypes.has(requestAttrType)) { + errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`); + } + } + if (template.keyUsages) { + if (template.keyUsages.requiredUsages && template.keyUsages.requiredUsages.all.length > 0) { + const missingRequired = template.keyUsages.requiredUsages.all.filter( + (usage) => !request.keyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + } + } + + if (request.keyUsages && (template.keyUsages.requiredUsages || template.keyUsages.optionalUsages)) { + const allAllowedUsages = [ + ...(template.keyUsages.requiredUsages?.all || []), + ...(template.keyUsages.optionalUsages?.all || []) + ]; + + if (allAllowedUsages.length > 0) { + 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)`); + } + if (template.extendedKeyUsages) { - const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter( - (usage) => !request.extendedKeyUsages?.includes(usage) - ); - if (missingRequired.length > 0) { - errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); - } - - if (request.extendedKeyUsages) { - const allAllowedUsages = [ - ...template.extendedKeyUsages.requiredUsages.all, - ...template.extendedKeyUsages.optionalUsages.all - ]; - const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage)); - if (invalidUsages.length > 0) { - errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`); + if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) { + const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter( + (usage) => !request.extendedKeyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); } } + + if ( + request.extendedKeyUsages && + (template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages) + ) { + const allAllowedUsages = [ + ...(template.extendedKeyUsages.requiredUsages?.all || []), + ...(template.extendedKeyUsages.optionalUsages?.all || []) + ]; + + if (allAllowedUsages.length > 0) { + const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`); + } + } + } + } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { + errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); } + const templateSanTypes = new Set(template.subjectAlternativeNames?.map((san) => san.type) || []); + + const sanPoliciesByType = new Map(); template.subjectAlternativeNames?.forEach((sanPolicy) => { - const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanPolicy.type) || []; - - if (sanPolicy.include === "mandatory") { - if (requestSans.length === 0) { - errors.push(`${sanPolicy.type} SAN is mandatory but not provided in request`); - } else if (sanPolicy.value && sanPolicy.value.length > 0) { - const hasWildcards = sanPolicy.value.some((val) => val.includes("*")); - requestSans.forEach((san) => { - const isValidValue = sanPolicy.value!.some((allowedValue) => { - if (allowedValue.includes("*")) { - // Handle wildcard patterns - escape dots and replace * with .* - const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); - const regex = new RE2(`^${pattern}$`); - return regex.test(san.value); - } - return allowedValue === san.value; - }); - if (!isValidValue) { - if (hasWildcards) { - errors.push( - `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}` - ); - } else { - errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`); - } - } - }); - } - } - - if (sanPolicy.include === "prohibit" && requestSans.length > 0) { - errors.push(`${sanPolicy.type} SAN is prohibited by template policy`); - } - - if (sanPolicy.include === "optional" && sanPolicy.value && sanPolicy.value.length > 0) { - const hasWildcards = sanPolicy.value.some((val) => val.includes("*")); - requestSans.forEach((san) => { - const isValidValue = sanPolicy.value!.some((allowedValue) => { - if (allowedValue.includes("*")) { - // Handle wildcard patterns - escape dots and replace * with .* - const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); - const regex = new RE2(`^${pattern}$`); - return regex.test(san.value); - } - return allowedValue === san.value; - }); - if (!isValidValue) { - if (hasWildcards) { - errors.push( - `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}` - ); - } else { - errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`); - } - } - }); - } + const existing = sanPoliciesByType.get(sanPolicy.type) || []; + sanPoliciesByType.set(sanPolicy.type, [...existing, sanPolicy]); }); - if (request.signatureAlgorithm && template.signatureAlgorithm) { - if (!template.signatureAlgorithm?.allowedAlgorithms.includes(request.signatureAlgorithm)) { - errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + for (const [sanType, policies] of sanPoliciesByType) { + const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanType) || []; + + const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY); + const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT); + + if (hasProhibit && requestSans.length > 0) { + errors.push(`${sanType} SAN is prohibited by template policy`); + // eslint-disable-next-line no-continue + continue; + } + + if (hasMandatory && requestSans.length === 0) { + errors.push(`${sanType} SAN is mandatory but not provided in request`); + // eslint-disable-next-line no-continue + continue; + } + + if (requestSans.length > 0) { + const policiesWithValues = policies.filter( + (p) => + p.value && + p.value.length > 0 && + (p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL) + ); + + if (policiesWithValues.length > 0) { + const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []); + + requestSans.forEach((san) => { + const validation = validateValueAgainstConstraints(san.value, allAllowedValues, `${sanType} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + }); + } } } - if (request.keyAlgorithm && template.keyAlgorithm) { - if (!template.keyAlgorithm?.allowedKeyTypes.includes(request.keyAlgorithm)) { - errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + const requestSanTypes = new Set(request.subjectAlternativeNames?.map((san) => san.type) || []); + for (const requestSanType of requestSanTypes) { + if (!templateSanTypes.has(requestSanType)) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + + if (request.signatureAlgorithm) { + if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) { + const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map( + mapTemplateSignatureAlgorithmToApi + ); + if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { + errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + } + } else if (!template.signatureAlgorithm) { + errors.push( + `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + if (request.keyAlgorithm) { + if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) { + const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi); + if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { + errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + } + } else if (!template.keyAlgorithm) { + errors.push( + `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` + ); } } @@ -335,6 +427,44 @@ export const certificateTemplateV2ServiceFactory = ({ } } + 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"); + } + + if ((request.notBefore || request.notAfter) && template.validity) { + 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 maxDuration = convertToMilliseconds( + template.validity.maxDuration.value, + template.validity.maxDuration.unit + ); + + if (requestDuration > maxDuration) { + errors.push(`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration`); + } + + if (template.validity.minDuration) { + const minDuration = convertToMilliseconds( + template.validity.minDuration.value, + template.validity.minDuration.unit + ); + if (requestDuration < minDuration) { + errors.push(`Requested validity period (notBefore to notAfter) is below minimum required duration`); + } + } + } + } + return { isValid: errors.length === 0, errors, @@ -375,15 +505,9 @@ export const certificateTemplateV2ServiceFactory = ({ throw new Error("Template data is required"); } - validateTemplatePolicy({ - attributes: data.attributes, - keyUsages: data.keyUsages, - extendedKeyUsages: data.extendedKeyUsages, - subjectAlternativeNames: data.subjectAlternativeNames, - validity: data.validity, - signatureAlgorithm: data.signatureAlgorithm, - keyAlgorithm: data.keyAlgorithm - }); + if (data.attributes) { + validateSubjectAttributePolicy(data.attributes); + } const slug = data.slug || generateTemplateSlug(); const uniqueSlug = await ensureUniqueSlug(projectId, slug); @@ -431,18 +555,8 @@ export const certificateTemplateV2ServiceFactory = ({ ProjectPermissionSub.CertificateTemplates ); - if (hasAnyPolicyField(data)) { - const mergedPolicy = { - attributes: data.attributes || existingTemplate.attributes, - keyUsages: data.keyUsages || existingTemplate.keyUsages, - extendedKeyUsages: data.extendedKeyUsages || existingTemplate.extendedKeyUsages, - subjectAlternativeNames: data.subjectAlternativeNames || existingTemplate.subjectAlternativeNames, - validity: data.validity || existingTemplate.validity, - signatureAlgorithm: data.signatureAlgorithm || existingTemplate.signatureAlgorithm, - keyAlgorithm: data.keyAlgorithm || existingTemplate.keyAlgorithm - }; - - validateTemplatePolicy(mergedPolicy); + if (data.attributes) { + validateSubjectAttributePolicy(data.attributes); } const updateData = { ...data }; @@ -614,8 +728,14 @@ export const certificateTemplateV2ServiceFactory = ({ const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); if (isInUse) { + const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); + const profileNames = profilesUsingTemplate.map((profile) => profile.slug || profile.id).join(", "); + throw new ForbiddenRequestError({ - message: "Cannot delete template that is in use by certificate profiles" + message: + profilesUsingTemplate.length > 0 + ? `Cannot delete template '${template.slug}' 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.slug}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` }); } 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 index 17cd96642..dd5cc9f27 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -1,27 +1,35 @@ import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2"; +import { + CertDurationUnit, + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; export interface TTemplateV2Policy { attributes: Array<{ - type: "common_name"; - include: "mandatory" | "optional" | "prohibit"; + type: CertSubjectAttributeType; + include: CertIncludeType; value?: string[]; }>; keyUsages: { - requiredUsages: { all: string[] }; - optionalUsages: { all: string[] }; + requiredUsages?: { all: CertKeyUsageType[] }; + optionalUsages?: { all: CertKeyUsageType[] }; }; extendedKeyUsages: { - requiredUsages: { all: string[] }; - optionalUsages: { all: string[] }; + requiredUsages?: { all: CertExtendedKeyUsageType[] }; + optionalUsages?: { all: CertExtendedKeyUsageType[] }; }; subjectAlternativeNames: Array<{ - type: "dns_name" | "ip_address" | "email" | "uri"; - include: "mandatory" | "optional" | "prohibit"; + type: CertSubjectAlternativeNameType; + include: CertIncludeType; value?: string[]; }>; validity: { - maxDuration: { value: number; unit: "days" | "months" | "years" }; - minDuration?: { value: number; unit: "days" | "months" | "years" }; + maxDuration: { value: number; unit: CertDurationUnit }; + minDuration?: { value: number; unit: CertDurationUnit }; }; signatureAlgorithm: { allowedAlgorithms: string[]; @@ -88,15 +96,25 @@ export type TCertificateTemplateV2Update = Partial< export interface TCertificateRequest { commonName?: string; - keyUsages?: string[]; - extendedKeyUsages?: string[]; + organizationName?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; subjectAlternativeNames?: Array<{ - type: "dns_name" | "ip_address" | "email" | "uri"; + type: CertSubjectAlternativeNameType; value: string; }>; validity?: { ttl: string; }; + notBefore?: Date; + notAfter?: Date; signatureAlgorithm?: string; keyAlgorithm?: string; } 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 index b078bde1a..3540a2b71 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -7,7 +7,13 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; -import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { ActorType, AuthMethod } from "../auth/auth-type"; @@ -76,8 +82,8 @@ describe("CertificateV3Service", () => { describe("issueCertificateFromProfile", () => { const mockCertificateRequest = { commonName: "test.example.com", - keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], - extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], validity: { ttl: "30d" }, signatureAlgorithm: "RSA-SHA256", keyAlgorithm: "RSA_2048" @@ -102,12 +108,19 @@ describe("CertificateV3Service", () => { id: "template-123", signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, keyAlgorithm: { defaultKeyType: "RSA_2048" }, - attributes: [] + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] }; const mockCertificateResult = { certificate: Buffer.from("cert"), certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("issuing-ca"), privateKey: Buffer.from("key"), serialNumber: "123456" }; @@ -136,6 +149,8 @@ describe("CertificateV3Service", () => { }); 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"); @@ -160,12 +175,19 @@ describe("CertificateV3Service", () => { id: "template-123", signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, keyAlgorithm: { defaultKeyType: "RSA_2048" }, - attributes: [] + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] }; const mockCertificateResult = { certificate: Buffer.from("cert"), certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("issuing-ca"), privateKey: Buffer.from("key"), serialNumber: "123456" }; @@ -178,17 +200,17 @@ describe("CertificateV3Service", () => { const camelCaseRequest = { commonName: "test.example.com", keyUsages: [ - CertKeyUsage.DIGITAL_SIGNATURE, - CertKeyUsage.NON_REPUDIATION, - CertKeyUsage.KEY_AGREEMENT, - CertKeyUsage.CRL_SIGN, - CertKeyUsage.DECIPHER_ONLY + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY ], extendedKeyUsages: [ - CertExtendedKeyUsage.CLIENT_AUTH, - CertExtendedKeyUsage.CODE_SIGNING, - CertExtendedKeyUsage.OCSP_SIGNING, - CertExtendedKeyUsage.SERVER_AUTH + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH ], validity: { ttl: "10d" } }; @@ -215,8 +237,19 @@ describe("CertificateV3Service", () => { expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( "template-123", expect.objectContaining({ - keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"], - extendedKeyUsages: ["client_auth", "code_signing", "ocsp_signing", "server_auth"] + 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 + ] }) ); }); @@ -286,6 +319,7 @@ describe("CertificateV3Service", () => { const mockSignResult = { certificate: Buffer.from("signed-cert"), certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("issuing-ca"), serialNumber: "789012" }; @@ -308,6 +342,8 @@ describe("CertificateV3Service", () => { }); 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"); @@ -347,11 +383,11 @@ describe("CertificateV3Service", () => { describe("orderCertificateFromProfile", () => { const mockCertificateOrder = { - subjectAlternativeNames: [{ type: "dns" as const, value: "example.com" }], + altNames: [{ type: ACMESANType.DNS, value: "example.com" }], validity: { ttl: "30d" }, commonName: "example.com", - keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], - extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], signatureAlgorithm: "RSA-SHA256", keyAlgorithm: "RSA_2048" }; @@ -375,12 +411,19 @@ describe("CertificateV3Service", () => { id: "template-123", signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, keyAlgorithm: { defaultKeyType: "RSA_2048" }, - attributes: [] + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] }; const mockCertificateResult = { certificate: Buffer.from("cert"), certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("issuing-ca"), privateKey: Buffer.from("key"), serialNumber: "123456" }; @@ -413,9 +456,9 @@ describe("CertificateV3Service", () => { expect(result).toHaveProperty("certificate"); expect(result.subjectAlternativeNames).toHaveLength(1); expect(result.subjectAlternativeNames[0]).toEqual({ - type: "dns", + type: ACMESANType.DNS, value: "example.com", - status: "valid" + status: CertificateOrderStatus.VALID }); }); @@ -448,4 +491,230 @@ describe("CertificateV3Service", () => { ).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 + }; + + 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", + internalCa: { + keyAlgorithm: "RSA_2048" + } + }; + + const rsaTemplate = { + id: "template-1", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"] + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(rsaCa); + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] }); + mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(rsaTemplate); + mockInternalCaService.issueCertFromCa.mockResolvedValue({ + certificate: Buffer.from("cert"), + certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("ca-cert"), + privateKey: Buffer.from("key"), + serialNumber: "123456" + }); + mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" }); + mockCertificateDAL.updateById.mockResolvedValue(undefined); + + // 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", + internalCa: { + keyAlgorithm: "EC_prime256v1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"] + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(ecCa); + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] }); + mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(ecdsaTemplate); + mockInternalCaService.issueCertFromCa.mockResolvedValue({ + certificate: Buffer.from("cert"), + certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("ca-cert"), + privateKey: Buffer.from("key"), + serialNumber: "123456" + }); + mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" }); + mockCertificateDAL.updateById.mockResolvedValue(undefined); + + // 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", + internalCa: { + keyAlgorithm: "RSA_8192" // Future RSA key size + } + }; + + const rsaTemplate = { + id: "template-1", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA"] + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(rsa8192Ca); + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] }); + mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(rsaTemplate); + mockInternalCaService.issueCertFromCa.mockResolvedValue({ + certificate: Buffer.from("cert"), + certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("ca-cert"), + privateKey: Buffer.from("key"), + serialNumber: "123456" + }); + mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" }); + mockCertificateDAL.updateById.mockResolvedValue(undefined); + + // 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", + internalCa: { + keyAlgorithm: "EC_secp521r1" // Future EC curve + } + }; + + const ecdsaTemplate = { + id: "template-1", + signatureAlgorithm: { + allowedAlgorithms: ["SHA384-ECDSA"] + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(newEcCa); + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] }); + mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(ecdsaTemplate); + mockInternalCaService.issueCertFromCa.mockResolvedValue({ + certificate: Buffer.from("cert"), + certificateChain: Buffer.from("chain"), + issuingCaCertificate: Buffer.from("ca-cert"), + privateKey: Buffer.from("key"), + serialNumber: "123456" + }); + mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" }); + mockCertificateDAL.updateById.mockResolvedValue(undefined); + + // 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 index cdf98c215..831256de3 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -10,6 +10,7 @@ import { 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 } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory, TCertificateAuthorityWithAssociatedCa @@ -20,10 +21,13 @@ import { TCertificateProfileDALFactory } from "@app/services/certificate-profile 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"; @@ -95,6 +99,45 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: return caType; }; +const validateAlgorithmCompatibility = ( + ca: TCertificateAuthorityWithAssociatedCa, + template: { + signatureAlgorithm?: { + allowedAlgorithms?: string[]; + }; + } +) => { + if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) { + return; + } + + const caKeyAlgorithm = ca.internalCa?.keyAlgorithm; + if (!caKeyAlgorithm) { + throw new BadRequestError({ message: "CA key algorithm not found" }); + } + + const compatibleAlgorithms = template.signatureAlgorithm.allowedAlgorithms.filter((sigAlg: string) => { + const parts = sigAlg.split("-"); + 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.signatureAlgorithm.allowedAlgorithms.join(", ")}) 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); @@ -131,6 +174,12 @@ export const certificateV3ServiceFactory = ({ 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); const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, @@ -166,33 +215,48 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate template not found for this profile" }); } + validateAlgorithmCompatibility(ca, template); + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType; + if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request or template default" + }); + } + + if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request or template default" + }); + } + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes); const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( - certificateRequest, + { subjectAlternativeNames: certificateRequest.altNames }, template.subjectAlternativeNames ); - const { certificate, certificateChain, privateKey, serialNumber } = await internalCaService.issueCertFromCa({ - caId: ca.id, - friendlyName: certificateSubject.common_name || "Certificate", - commonName: certificateSubject.common_name || "", - altNames: subjectAlternativeNames, - ttl: certificateRequest.validity.ttl, - keyUsages: certificateRequest.keyUsages, - extendedKeyUsages: certificateRequest.extendedKeyUsages, - notBefore: normalizeDateForApi(certificateRequest.notBefore), - notAfter: normalizeDateForApi(certificateRequest.notAfter), - signatureAlgorithm: effectiveSignatureAlgorithm, - keyAlgorithm: effectiveKeyAlgorithm, - actor, - actorId, - actorAuthMethod, - actorOrgId - }); + 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 + }); const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); if (!cert) { @@ -201,14 +265,15 @@ export const certificateV3ServiceFactory = ({ await certificateDAL.updateById(cert.id, { profileId }); - const certificateChainString = bufferToString(certificateChain); return { certificate: bufferToString(certificate), - issuingCaCertificate: certificateChainString.split("\n").pop() || bufferToString(certificate), - certificateChain: certificateChainString, + issuingCaCertificate: bufferToString(issuingCaCertificate), + certificateChain: bufferToString(certificateChain), privateKey: bufferToString(privateKey), serialNumber, - certificateId: cert.id + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug }; }; @@ -218,6 +283,8 @@ export const certificateV3ServiceFactory = ({ validity, notBefore, notAfter, + signatureAlgorithm, + keyAlgorithm, actor, actorId, actorAuthMethod, @@ -241,16 +308,52 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "CSR signing"); - const { certificate, certificateChain, serialNumber } = await internalCaService.signCertFromCa({ - isInternal: true, - caId: ca.id, - csr, - ttl: validity.ttl, - altNames: "", - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter) + 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 || template.signatureAlgorithm?.defaultAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType; + + if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request or template default" + }); + } + + if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request or template default" + }); + } + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: "", + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm + }); + 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" }); @@ -263,10 +366,12 @@ export const certificateV3ServiceFactory = ({ return { certificate: certificateString, - issuingCaCertificate: certificateChainString.split("\n").pop() || certificateString, + issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), certificateChain: certificateChainString, serialNumber, - certificateId: cert.id + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug }; }; @@ -293,8 +398,8 @@ export const certificateV3ServiceFactory = ({ commonName: certificateOrder.commonName, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.subjectAlternativeNames.map((san) => ({ - type: san.type === "dns" ? ("dns_name" as const) : ("ip_address" as const), + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, value: san.value })), validity: certificateOrder.validity, @@ -334,43 +439,26 @@ export const certificateV3ServiceFactory = ({ }); const orderId = randomUUID(); - const subjectAlternativeNames = certificateOrder.subjectAlternativeNames.map((san) => ({ - type: san.type, - value: san.value, - status: "valid" as const - })); - - const authorizations = certificateOrder.subjectAlternativeNames.map((san) => ({ - identifier: { - type: san.type, - value: san.value - }, - status: "valid" as const, - expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - challenges: [ - { - type: "internal-validation", - status: "valid" as const, - url: `/api/v3/certificates/orders/${orderId}/internal`, - token: "internal-ca-validation" - } - ] - })); return { orderId, - status: "valid", - subjectAlternativeNames, - authorizations, - finalize: `/api/v3/certificates/orders/${orderId}/finalize`, - certificate: certificateResult.certificate + 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. Use direct certificate issuance for ACME CAs." + message: "ACME certificate ordering via profiles is not yet implemented." }); } diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index c1d69b2f5..b54042c5c 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -1,15 +1,20 @@ import { TProjectPermission } from "@app/lib/types"; -import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-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?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; - subjectAlternativeNames?: Array<{ - type: "dns_name" | "ip_address" | "email" | "uri"; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; value: string; }>; validity: { @@ -30,21 +35,23 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; } & Omit; export type TOrderCertificateFromProfileDTO = { profileId: string; certificateOrder: { - subjectAlternativeNames: Array<{ - type: "dns" | "ip"; + altNames: Array<{ + type: ACMESANType; value: string; }>; validity: { ttl: string; }; commonName?: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; notBefore?: Date; notAfter?: Date; signatureAlgorithm?: string; @@ -59,30 +66,34 @@ export type TCertificateFromProfileResponse = { privateKey?: string; serialNumber: string; certificateId: string; + projectId: string; + profileName: string; }; export type TCertificateOrderResponse = { orderId: string; - status: "pending" | "processing" | "valid" | "invalid"; + status: CertificateOrderStatus; subjectAlternativeNames: Array<{ - type: "dns" | "ip"; + type: ACMESANType; value: string; - status: "pending" | "processing" | "valid" | "invalid"; + status: CertificateOrderStatus; }>; authorizations: Array<{ identifier: { - type: "dns" | "ip"; + type: ACMESANType; value: string; }; - status: "pending" | "processing" | "valid" | "invalid"; + status: CertificateOrderStatus; expires?: string; challenges: Array<{ type: string; - status: "pending" | "processing" | "valid" | "invalid"; + 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..ff231e88d 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -18,6 +18,15 @@ export enum CertKeyAlgorithm { 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 enum CertKeyUsage { DIGITAL_SIGNATURE = "digitalSignature", KEY_ENCIPHERMENT = "keyEncipherment", @@ -111,7 +120,26 @@ export enum TAltNameType { IP = "ip", URL = "url" } + +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} 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/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts index b63fb0316..c13818270 100644 --- a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts @@ -32,16 +32,6 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => { } }; - const deleteById = async (id: string, tx?: Knex) => { - try { - const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).del().returning("*"); - - return estConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Delete EST enrollment config" }); - } - }; - const findById = async (id: string, tx?: Knex) => { try { const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); @@ -52,25 +42,10 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => { } }; - const isConfigInUse = async (configId: string, tx?: Knex) => { - try { - const profileCount = await (tx || db)(TableName.CertificateProfile) - .where({ estConfigId: configId }) - .count("* as count") - .first(); - - return parseInt(profileCount || "0", 10) > 0; - } catch (error) { - throw new DatabaseError({ error, name: "Check if EST enrollment config is in use" }); - } - }; - return { ...estEnrollmentConfigOrm, create, updateById, - deleteById, - findById, - isConfigInUse + 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 index 56fcf3791..f159aff34 100644 --- a/docs/api-reference/endpoints/certificate-templates/create.mdx +++ b/docs/api-reference/endpoints/certificate-templates/create.mdx @@ -2,3 +2,9 @@ title: "Create" openapi: "POST /api/v1/pki/certificate-templates" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead. + diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx index c4f13d470..48c3fd755 100644 --- a/docs/api-reference/endpoints/certificate-templates/delete.mdx +++ b/docs/api-reference/endpoints/certificate-templates/delete.mdx @@ -2,3 +2,9 @@ title: "Delete" openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead. + diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx index 802dc5326..09fcbd028 100644 --- a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx +++ b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx @@ -2,3 +2,9 @@ title: "Get by ID" openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead. + diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx index 53c5f6fdf..9db27e5e6 100644 --- a/docs/api-reference/endpoints/certificate-templates/update.mdx +++ b/docs/api-reference/endpoints/certificate-templates/update.mdx @@ -2,3 +2,9 @@ title: "Update" openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx index 14a53b7fa..8285e2bdc 100644 --- a/docs/api-reference/endpoints/pki/subscribers/create.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/create.mdx @@ -2,3 +2,9 @@ title: "Create" openapi: "POST /api/v1/pki/subscribers" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx index 5975b89e9..a553c2f9e 100644 --- a/docs/api-reference/endpoints/pki/subscribers/delete.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/delete.mdx @@ -2,3 +2,9 @@ title: "Delete" openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + 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 index 894c8ed4e..1b6cfe19a 100644 --- a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx @@ -2,3 +2,9 @@ title: "Retrieve latest certificate bundle" openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx index c9c71c80d..2ecd4bf50 100644 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx @@ -2,3 +2,9 @@ title: "Issue Certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx index 3a4607303..1afb5e32a 100644 --- a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx @@ -2,3 +2,9 @@ title: "List Certificates" openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx index 93abf1433..ae512b071 100644 --- a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx @@ -2,3 +2,9 @@ title: "Order Certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx index 0d223217d..f18690c5e 100644 --- a/docs/api-reference/endpoints/pki/subscribers/read.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/read.mdx @@ -2,3 +2,9 @@ title: "Retrieve" openapi: "GET /api/v1/pki/subscribers/{subscriberName}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx index d31d30239..9672bfb82 100644 --- a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx @@ -2,3 +2,9 @@ title: "Sign Certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx index 5b62cbe7d..e85c6e28c 100644 --- a/docs/api-reference/endpoints/pki/subscribers/update.mdx +++ b/docs/api-reference/endpoints/pki/subscribers/update.mdx @@ -2,3 +2,9 @@ title: "Update" openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" --- + + +**Deprecated API Endpoint** + +This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead. + diff --git a/docs/docs.json b/docs/docs.json index 444f74b5d..784b015e6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -2493,10 +2493,21 @@ { "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", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/certificate-templates/list", + "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" + ] + } ] }, { @@ -2520,6 +2531,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/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..0899d6b2a 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", @@ -217,6 +225,7 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.SshHosts | ProjectPermissionSub.PkiSubscribers | ProjectPermissionSub.CertificateTemplates + | ProjectPermissionSub.CertificateProfiles | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretRotation @@ -293,6 +302,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 +480,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 a93e0caf9..baa9b4ed3 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,10 +1,12 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; +export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types"; export { useCreateCa, useCreateCertificate, useCreateCertificateV3, useDeleteCa, useImportCaCertificate, + useOrderCertificateWithProfile, useRenewCa, useSignIntermediate, useUpdateCa diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index a486df62e..c0bd8abf1 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -14,6 +14,8 @@ import { TDeleteCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, + TOrderCertificateDTO, + TOrderCertificateResponse, TRenewCaDTO, TRenewCaResponse, TSignIntermediateDTO, @@ -168,6 +170,24 @@ export const useCreateCertificateV3 = () => { }); }; +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 78396a904..d782b78e9 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; @@ -185,7 +185,7 @@ export type TCreateCertificateV3DTO = { email?: string; streetAddress?: string; postalCode?: string; - altNames: string; + subjectAltNames: string; ttl: string; notBefore?: string; notAfter?: string; @@ -197,6 +197,54 @@ export type TCreateCertificateV3DTO = { export type TCreateCertificateV3Response = TCreateCertificateResponse; +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?: any; + }>; + }>; + certificate?: string; + privateKey?: string; + expires: string; + notBefore: string; + notAfter: string; +}; + export type TRenewCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index a02856a0f..fb5952769 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -124,16 +124,7 @@ export type TListCertificateTemplatesDTO = { export type TCertificateTemplateV2Policy = { attributes: Array<{ - type: - | "common_name" - | "organization_name" - | "organization_unit" - | "locality" - | "state" - | "country" - | "email" - | "street_address" - | "postal_code"; + type: "common_name"; include: "mandatory" | "optional" | "prohibit"; value?: string[]; }>; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index ecda22afb..6ee03c308 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -25,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/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/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 93e8b7e76..bed78b434 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -18,7 +18,7 @@ import { Link, Outlet } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; import { useListWorkspaceCertificateTemplates, useListWorkspacePkiSubscribers @@ -29,6 +29,7 @@ import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePri export const PkiManagerLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const { subscription } = useSubscription(); const { t } = useTranslation(); const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); @@ -39,7 +40,8 @@ export const PkiManagerLayout = () => { const hasExistingSubscribers = subscribers.length > 0; const hasExistingTemplates = templates.length > 0; - const showLegacySection = hasExistingSubscribers || hasExistingTemplates; + const showLegacySection = + subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates; return ( <> @@ -166,7 +168,7 @@ export const PkiManagerLayout = () => { {showLegacySection && ( - {hasExistingSubscribers && ( + {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( { )} )} - {hasExistingTemplates && ( + {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( { certificatePem, privateKeyPem, chainPem, - friendlyName, collectionId }: FormData) => { try { @@ -84,8 +80,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { certificatePem, privateKeyPem, chainPem, - - friendlyName, pkiCollectionId: collectionId }); @@ -150,20 +144,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - ( - - - - )} - /> , state?: boolean ) => void; + profileId?: string; }; type TCertificateDetails = { @@ -107,7 +105,7 @@ type TCertificateDetails = { privateKey: string; }; -export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => { +export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); const [allowedKeyUsages, setAllowedKeyUsages] = useState([]); const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState([]); @@ -135,10 +133,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => } = useForm({ resolver: zodResolver(schema), defaultValues: { - profileId: "", - friendlyName: "", + profileId: profileId ? profileId : "", subjectAttributes: [{ type: "common_name", value: "" }], - altNames: [], + subjectAltNames: [], ttl: "30d", signatureAlgorithm: "", keyAlgorithm: "", @@ -158,9 +155,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => useEffect(() => { if (templateData && selectedProfile) { - if (templateData.signatureAlgorithm?.defaultAlgorithm) { - let sigAlgValue = templateData.signatureAlgorithm.defaultAlgorithm; - + if (templateData.signatureAlgorithm?.allowedAlgorithms && templateData.signatureAlgorithm.allowedAlgorithms.length > 0) { const sigAlgMap: Record = { "SHA256-RSA": "RSA-SHA256", "SHA384-RSA": "RSA-SHA384", @@ -170,15 +165,20 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => "SHA512-ECDSA": "ECDSA-SHA512" }; - if (sigAlgMap[sigAlgValue]) { - sigAlgValue = sigAlgMap[sigAlgValue]; + let defaultValue = templateData.signatureAlgorithm.defaultAlgorithm; + if (defaultValue && sigAlgMap[defaultValue]) { + defaultValue = sigAlgMap[defaultValue]; } - setValue("signatureAlgorithm", sigAlgValue); - } - if (templateData.keyAlgorithm?.defaultKeyType) { - let keyAlgValue = templateData.keyAlgorithm.defaultKeyType; + const allowedValues = templateData.signatureAlgorithm.allowedAlgorithms.map((alg: string) => sigAlgMap[alg] || alg); + if (defaultValue && allowedValues.includes(defaultValue)) { + setValue("signatureAlgorithm", defaultValue); + } else if (allowedValues.length > 0) { + setValue("signatureAlgorithm", allowedValues[0]); + } + } + if (templateData.keyAlgorithm?.allowedKeyTypes && templateData.keyAlgorithm.allowedKeyTypes.length > 0) { const keyAlgMap: Record = { "RSA-2048": CertKeyAlgorithm.RSA_2048, "RSA-3072": CertKeyAlgorithm.RSA_3072, @@ -189,13 +189,39 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => [CertKeyAlgorithm.ECDSA_P384]: CertKeyAlgorithm.ECDSA_P384 }; - if (keyAlgMap[keyAlgValue]) { - keyAlgValue = keyAlgMap[keyAlgValue]; - } else { - keyAlgValue = templateData.keyAlgorithm.defaultKeyType; + let defaultValue = templateData.keyAlgorithm.defaultKeyType; + if (defaultValue && keyAlgMap[defaultValue]) { + defaultValue = keyAlgMap[defaultValue]; } - setValue("keyAlgorithm", keyAlgValue); + const allowedValues = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => keyAlgMap[alg] || alg); + + if (defaultValue && allowedValues.includes(defaultValue)) { + setValue("keyAlgorithm", defaultValue); + } else if (allowedValues.length > 0) { + setValue("keyAlgorithm", allowedValues[0]); + } + } + + if (templateData.validity?.maxDuration) { + const { value, unit } = templateData.validity.maxDuration; + let ttlValue = ""; + + switch (unit) { + case "days": + ttlValue = `${value}d`; + break; + case "months": + ttlValue = `${value}m`; + break; + case "years": + ttlValue = `${value}y`; + break; + default: + ttlValue = `${value}d`; + } + + setValue("ttl", ttlValue); } if (templateData.signatureAlgorithm?.allowedAlgorithms) { @@ -269,6 +295,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => if (subjectAttrs.length > 0) { setValue("subjectAttributes", subjectAttrs); + } else { + setValue("subjectAttributes", [{ type: "common_name", value: "" }]); } } @@ -303,7 +331,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => }); if (templateSans.length > 0) { - setValue("altNames", templateSans); + setValue("subjectAltNames", templateSans); } } @@ -394,13 +422,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => reset({ profileId: "", - friendlyName: cert.friendlyName, subjectAttributes: subjectAttrs.length > 0 ? (subjectAttrs as any) : [{ type: "common_name" as const, value: "" }], - altNames: cert.altNames - ? cert.altNames.split(",").map((name) => { + subjectAltNames: cert.subjectAltNames + ? cert.subjectAltNames.split(",").map((name) => { const trimmed = name.trim(); if (trimmed.includes("@")) return { type: "email" as const, value: trimmed }; if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/)) @@ -418,11 +445,16 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => } }, [cert, reset]); + useEffect(() => { + if (popUp?.certificateIssuance?.isOpen && profileId && !cert) { + setValue("profileId", profileId); + } + }, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]); + const onFormSubmit = async ({ profileId, - friendlyName, subjectAttributes, - altNames, + subjectAltNames, ttl, signatureAlgorithm, keyAlgorithm, @@ -440,21 +472,20 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ profileId, projectSlug: currentProject.slug, - friendlyName, commonName: getAttributeValue("common_name"), - altNames: altNames + subjectAltNames: subjectAltNames .filter((san) => san.value.trim()) .map((san) => san.value.trim()) .join(", "), ttl, signatureAlgorithm: (() => { const frontendToBackendSigAlg: Record = { - "RSA-SHA256": "SHA256-RSA", - "RSA-SHA384": "SHA384-RSA", - "RSA-SHA512": "SHA512-RSA", - "ECDSA-SHA256": "SHA256-ECDSA", - "ECDSA-SHA384": "SHA384-ECDSA", - "ECDSA-SHA512": "SHA512-ECDSA" + "RSA-SHA256": "RSA-SHA256", + "RSA-SHA384": "RSA-SHA384", + "RSA-SHA512": "RSA-SHA512", + "ECDSA-SHA256": "ECDSA-SHA256", + "ECDSA-SHA384": "ECDSA-SHA384", + "ECDSA-SHA512": "ECDSA-SHA512" }; return signatureAlgorithm ? frontendToBackendSigAlg[signatureAlgorithm] || signatureAlgorithm @@ -462,11 +493,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => })(), keyAlgorithm: (() => { const frontendToBackendKeyAlg: Record = { - RSA_2048: "RSA-2048", - RSA_3072: "RSA-3072", - RSA_4096: "RSA-4096", - EC_prime256v1: "ECDSA-P256", - EC_secp384r1: "ECDSA-P384" + RSA_2048: "RSA_2048", + RSA_3072: "RSA_3072", + RSA_4096: "RSA_4096", + EC_prime256v1: "EC_prime256v1", + EC_secp384r1: "EC_secp384r1" }; return keyAlgorithm ? frontendToBackendKeyAlg[keyAlgorithm] || keyAlgorithm : undefined; })(), @@ -536,428 +567,389 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => {cert && (
-

Certificate Details

-

Serial Number: {cert.serialNumber}

-

Common Name: {cert.commonName}

-

Status: {cert.status}

+

Certificate Details

+

Serial Number: {cert.serialNumber}

+

Common Name: {cert.commonName}

+

Status: {cert.status}

)} {!cert && !certificateDetails && (
- ( - - - Certificate profiles define the policies and enrollment methods for - certificate issuance. The selected profile will enforce validation - rules and determine the CA used for signing. - - } - > - - - } - /> - - } - errorText={error?.message} - isError={Boolean(error)} - isRequired - > - - - )} - /> - - ( - - - - )} - /> - - ( - -
- {value.map((attr, index) => ( -
- - - { - const newValue = [...value]; - newValue[index] = { ...attr, value: e.target.value }; - onChange(newValue); - }} - placeholder="example.com" - className="flex-1" - /> - {value.length > 1 && ( - { - const newValue = value.filter((_, i) => i !== index); - onChange(newValue); - }} - > - - - )} -
- ))} - -
-
- )} - /> - - ( - -
- {value.map((san, index) => ( -
- - - { - const newValue = [...value]; - newValue[index] = { ...san, value: e.target.value }; - onChange(newValue); - }} - placeholder={ - san.type === "dns" - ? "example.com or *.example.com" - : san.type === "ip" - ? "192.168.1.1" - : san.type === "email" - ? "admin@example.com" - : "https://example.com" + {!profileId && ( + ( + + + Certificate profiles define the policies and enrollment methods for + certificate issuance. The selected profile will enforce validation + rules and determine the CA used for signing. + + } + > + + } - className="flex-1" /> - { - const newValue = value.filter((_, i) => i !== index); - onChange(newValue); - }} - > - -
- ))} - +
)} /> - -
( + name="subjectAltNames" + render={({ field: { onChange, value }, fieldState: { error } }) => ( - +
+ {value.map((san, index) => ( +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={ + san.type === "dns" + ? "example.com or *.example.com" + : san.type === "ip" + ? "192.168.1.1" + : san.type === "email" + ? "admin@example.com" + : "https://example.com" + } + className="flex-1" + /> + { + const newValue = value.filter((_, i) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
)} /> -
- - - - Key Usages - -
- {KEY_USAGES_OPTIONS.filter(({ value }) => { - if (allowedKeyUsages.length === 0) return true; - const templateToEnumMap = { - digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, - key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, - non_repudiation: CertKeyUsage.NON_REPUDIATION, - data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, - key_agreement: CertKeyUsage.KEY_AGREEMENT, - key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, - crl_sign: CertKeyUsage.CRL_SIGN, - encipher_only: CertKeyUsage.ENCIPHER_ONLY, - decipher_only: CertKeyUsage.DECIPHER_ONLY - }; - return allowedKeyUsages.some( - (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value - ); - }).map(({ label, value }) => ( - ( -
- field.onChange(checked)} - /> - -
- )} - /> - ))} -
-
-
+ ( + + + + )} + /> - - Extended Key Usages - -
- {EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => { - if (allowedExtendedKeyUsages.length === 0) return true; - const templateToEnumMap = { - client_auth: CertExtendedKeyUsage.CLIENT_AUTH, - server_auth: CertExtendedKeyUsage.SERVER_AUTH, - code_signing: CertExtendedKeyUsage.CODE_SIGNING, - email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, - ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, - time_stamping: CertExtendedKeyUsage.TIMESTAMPING, - timestamping: CertExtendedKeyUsage.TIMESTAMPING - }; - return allowedExtendedKeyUsages.some( - (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value - ); - }).map(({ label, value }) => ( - ( -
- field.onChange(checked)} - /> - -
- )} - /> - ))} +
+
+ ( + + + + )} + />
- - - + +
+ ( + + + + )} + /> +
+
+ + + + Key Usages + +
+ {KEY_USAGES_OPTIONS.filter(({ value }) => { + if (allowedKeyUsages.length === 0) return true; + const templateToEnumMap = { + digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, + key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, + non_repudiation: CertKeyUsage.NON_REPUDIATION, + data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, + key_agreement: CertKeyUsage.KEY_AGREEMENT, + key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, + crl_sign: CertKeyUsage.CRL_SIGN, + encipher_only: CertKeyUsage.ENCIPHER_ONLY, + decipher_only: CertKeyUsage.DECIPHER_ONLY + }; + return allowedKeyUsages.some( + (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value + ); + }).map(({ label, value }) => ( + ( +
+ field.onChange(checked)} + /> + +
+ )} + /> + ))} +
+
+
+ + + Extended Key Usages + +
+ {EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => { + if (allowedExtendedKeyUsages.length === 0) return true; + const templateToEnumMap = { + client_auth: CertExtendedKeyUsage.CLIENT_AUTH, + server_auth: CertExtendedKeyUsage.SERVER_AUTH, + code_signing: CertExtendedKeyUsage.CODE_SIGNING, + email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, + ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, + time_stamping: CertExtendedKeyUsage.TIMESTAMPING, + timestamping: CertExtendedKeyUsage.TIMESTAMPING + }; + return allowedExtendedKeyUsages.some( + (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value + ); + }).map(({ label, value }) => ( + ( +
+ field.onChange(checked)} + /> + +
+ )} + /> + ))} +
+
+
+
+ + )}
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 projectId = currentProject.id; - const allowNewSubscriberCreation = false; + const allowNewSubscriberCreation = subscription.pkiLegacyTemplates; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); @@ -85,16 +87,16 @@ export const PkiSubscriberSection = () => { const subscriberName = subscriberStatusData?.subscriberName || ""; return ( -
+
-

Subscribers

+

Subscribers

-
+ { + subscription?.pkiLegacyTemplates && ( +

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 index 5f8923da9..64a8d940d 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -2,6 +2,7 @@ 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 { @@ -54,6 +55,10 @@ export const CertificateProfilesTab = () => { }); setIsDeleteModalOpen(false); setSelectedProfile(null); + createNotification({ + text: `Certificate profile "${selectedProfile.slug}" deleted successfully`, + type: "success" + }); } catch (error) { console.error("Failed to delete profile:", error); } diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index fc24b8c03..3255a82d9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -122,7 +122,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } const certificateAuthorities = caData || []; const certificateTemplates = templateData?.certificateTemplates || []; - const { control, handleSubmit, reset, watch, setValue } = useForm({ + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(isEdit ? editSchema : createSchema), defaultValues: isEdit ? { @@ -260,7 +260,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="certificateAuthorityId" render={({ field: { onChange, ...field }, fieldState: { error } }) => ( { - onChange(value); if (watchedEnrollmentType === "est") { setValue("estConfig", { disableBootstrapCaValidation: false, @@ -311,6 +310,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } }); setValue("estConfig", undefined); } + onChange(value); }} placeholder="Select a certificate template" className="w-full" @@ -339,7 +339,23 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } >
@@ -48,21 +41,32 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { - - + + - {profiles.map((profile) => ( - - ))} + {isLoading && } + {!isLoading && (!profiles || profiles.length === 0) && ( + + + + )} + {!isLoading && + profiles && + profiles.length > 0 && + profiles.map((profile) => ( + + ))}
Name Enrollment TypeCertificate AuthorityTemplateIssuing CACertificate Template Certificates
+ +
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 4fcc5f040..477590855 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-nested-ternary */ -import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -15,11 +15,16 @@ import { import { useProjectPermission } from "@app/context"; import { ProjectPermissionActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; import { useGetCaById } from "@app/hooks/api/ca/queries"; import { TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; +import { usePopUp, useToggle } from "@app/hooks"; +import { createNotification } from "@app/components/notifications"; +import { useCallback } from "react"; +import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; interface Props { profile: TCertificateProfile; @@ -32,6 +37,27 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = const { data: caData } = useGetCaById(profile.caId); + const { popUp, handlePopUpToggle } = usePopUp([ + "certificateIssuance" + ] as const); + + const [isIdCopied, setIsIdCopied] = useToggle(false); + + const handleCopyId = useCallback(() => { + setIsIdCopied.on(); + navigator.clipboard.writeText(profile.id); + + createNotification({ + text: "Profile ID copied to clipboard", + type: "info" + }); + + const timer = setTimeout(() => setIsIdCopied.off(), 2000); + + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [isIdCopied]); + const { data: templateData } = useGetCertificateTemplateV2ById({ templateId: profile.certificateTemplateId }); @@ -41,6 +67,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = ProjectPermissionSub.CertificateAuthorities ); + const canIssueCertificate = permission.can( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + const canDeleteProfile = permission.can( ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities @@ -128,6 +159,12 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
+ } + onClick={() => handleCopyId()} + > + Copy Profile ID + {canEditProfile && ( { @@ -139,6 +176,19 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = Edit Profile )} + { + canIssueCertificate && ( + { + e.stopPropagation(); + handlePopUpToggle("certificateIssuance"); + }} + icon={} + > + Issue Certificate + + ) + } {canDeleteProfile && ( { @@ -152,6 +202,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = )} + ); diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx index 9fda6af2c..5f17b9a92 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx @@ -2,6 +2,7 @@ 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 { @@ -52,7 +53,11 @@ export const CertificateTemplatesV2Tab = () => { }); setIsDeleteModalOpen(false); setSelectedTemplate(null); - } catch (error) { + createNotification({ + text: `Certificate template "${selectedTemplate.slug}" deleted successfully`, + type: "success" + }); + } catch (error: any) { console.error("Failed to delete template:", error); } }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx index 787bacdcf..7bc1cf15e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx @@ -1,5 +1,6 @@ +import { useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faExclamationTriangle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -18,7 +19,8 @@ import { ModalContent, Select, SelectItem, - TextArea + TextArea, + Tooltip } from "@app/components/v2"; import { useProject } from "@app/context"; import { @@ -27,7 +29,7 @@ import { } from "@app/hooks/api/certificateTemplates/mutations"; import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; -import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./shared/utils"; +import { INCLUDE_TYPE_OPTIONS, SAN_TYPE_OPTIONS, SUBJECT_ATTRIBUTE_TYPE_OPTIONS } from "./shared/certificate-constants"; import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared"; export type FormData = TemplateFormData; @@ -39,26 +41,18 @@ interface Props { mode?: "create" | "edit"; } -const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPES)[number], string> = { - common_name: "Common Name (CN)", - organization_name: "Organization (O)", - organization_unit: "Organizational Unit (OU)", - locality: "Locality (L)", - state: "State/Province (ST)", - country: "Country (C)", - email: "Email Address", - street_address: "Street Address", - postal_code: "Postal Code" +const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = { + common_name: "Common Name (CN)" }; -const SAN_TYPE_LABELS: Record<(typeof SAN_TYPES)[number], string> = { +const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = { dns_name: "DNS Name", ip_address: "IP Address", email: "Email", uri: "URI" }; -const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_OPTIONS)[number], string> = { +const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_TYPE_OPTIONS)[number], string> = { mandatory: "Mandatory", optional: "Optional", prohibit: "Prohibited" @@ -89,7 +83,36 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const isEdit = mode === "edit" && template; - const { control, handleSubmit, reset, watch, setValue } = useForm({ + const validateAttributeRules = (attributes: FormData["attributes"]) => { + if (!attributes) return { isValid: true, warnings: [], invalidIndices: [] }; + + const warnings: string[] = []; + const invalidIndices: number[] = []; + + const attributesByType = attributes.reduce((acc, attr, index) => { + if (!acc[attr.type]) acc[attr.type] = []; + acc[attr.type].push({ ...attr, index }); + return acc; + }, {} as Record>); + + Object.entries(attributesByType).forEach(([type, attrs]) => { + const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory'); + + if (mandatoryAttrs.length > 1) { + mandatoryAttrs.forEach(attr => invalidIndices.push(attr.index)); + warnings.push(`Multiple mandatory values found for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}. Only one mandatory value is allowed per attribute type.`); + } + + if (mandatoryAttrs.length === 1 && attrs.length > 1) { + attrs.forEach(attr => invalidIndices.push(attr.index)); + warnings.push(`When a mandatory value exists for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}, no other values (optional or forbidden) are allowed for that attribute type.`); + } + }); + + return { isValid: warnings.length === 0, warnings, invalidIndices }; + }; + + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(templateSchema), defaultValues: isEdit ? { @@ -138,13 +161,42 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const watchedAttributes = watch("attributes") || []; const watchedSans = watch("subjectAlternativeNames") || []; - const watchedKeyUsages = watch("keyUsages"); - const watchedExtendedKeyUsages = watch("extendedKeyUsages"); + const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] }; + const watchedExtendedKeyUsages = watch("extendedKeyUsages") || { requiredUsages: [], optionalUsages: [] }; + + const attributeValidation = useMemo(() => + validateAttributeRules(watchedAttributes), + [watchedAttributes] + ); const onFormSubmit = async (data: FormData) => { try { if (!currentProject?.id && !isEdit) return; + if (!attributeValidation.isValid) { + createNotification({ + text: "Please fix validation errors before submitting", + type: "error" + }); + return; + } + + const hasEmptyAttributeValues = data.attributes?.some(attr => + !attr.value || attr.value.length === 0 || attr.value.some(v => !v.trim()) + ); + + const hasEmptySanValues = data.subjectAlternativeNames?.some(san => + !san.value || san.value.length === 0 || san.value.some(v => !v.trim()) + ); + + if (hasEmptyAttributeValues || hasEmptySanValues) { + createNotification({ + text: "All values must be non-empty. Use wildcards (*) if needed.", + type: "error" + }); + return; + } + if (isEdit) { const updateData = { templateId: template.id, @@ -221,9 +273,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const addAttribute = () => { const newAttribute = { - type: SUBJECT_ATTRIBUTE_TYPES[0], - include: INCLUDE_OPTIONS[1], - value: [] + type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0], + include: INCLUDE_TYPE_OPTIONS[1], + value: ["*"] }; setValue("attributes", [...watchedAttributes, newAttribute]); }; @@ -235,9 +287,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const addSan = () => { const newSan = { - type: SAN_TYPES[0], - include: INCLUDE_OPTIONS[1], - value: [] + type: SAN_TYPE_OPTIONS[0], + include: INCLUDE_TYPE_OPTIONS[1], + value: ["*"] }; setValue("subjectAlternativeNames", [...watchedSans, newSan]); }; @@ -247,42 +299,18 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" setValue("subjectAlternativeNames", newSans); }; - const toggleKeyUsage = (usage: string, type: "required" | "optional") => { - const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] }; - const otherType = type === "required" ? "optional" : "required"; - const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : []; - const otherList = Array.isArray(current[`${otherType}Usages`]) - ? current[`${otherType}Usages`] - : []; - - const newOtherList = (otherList || []).filter((u) => u !== usage); - const newCurrentList = currentList?.includes(usage) - ? currentList.filter((u) => u !== usage) - : [...(currentList || []), usage]; - + const handleKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => { setValue("keyUsages", { - [`${type}Usages`]: newCurrentList, - [`${otherType}Usages`]: newOtherList - } as any); + requiredUsages: usages.requiredUsages as any, + optionalUsages: usages.optionalUsages as any + }); }; - const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => { - const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] }; - const otherType = type === "required" ? "optional" : "required"; - const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : []; - const otherList = Array.isArray(current[`${otherType}Usages`]) - ? current[`${otherType}Usages`] - : []; - - const newOtherList = (otherList || []).filter((u) => u !== usage); - const newCurrentList = currentList?.includes(usage) - ? currentList.filter((u) => u !== usage) - : [...(currentList || []), usage]; - + const handleExtendedKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => { setValue("extendedKeyUsages", { - [`${type}Usages`]: newCurrentList, - [`${otherType}Usages`]: newOtherList - } as any); + requiredUsages: usages.requiredUsages as any, + optionalUsages: usages.optionalUsages as any + }); }; return ( @@ -297,7 +325,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" > - - Basic Information - -
- ( - - - - )} - /> - - ( - -