diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts index 718ace6c2..d1ec020ce 100644 --- a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -10,20 +10,19 @@ export async function up(knex: Knex): Promise { t.string("projectId").notNullable(); t.foreign("projectId").references("id").inTable(TableName.Project); - t.string("slug").notNullable(); + t.string("name").notNullable(); t.string("description"); - t.jsonb("attributes"); + t.jsonb("subject"); + t.jsonb("sans"); t.jsonb("keyUsages"); t.jsonb("extendedKeyUsages"); - t.jsonb("subjectAlternativeNames"); + t.jsonb("algorithms"); t.jsonb("validity"); - t.jsonb("signatureAlgorithm"); - t.jsonb("keyAlgorithm"); t.timestamps(true, true, true); - t.unique(["slug", "projectId"]); + t.unique(["name", "projectId"]); }); await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2); diff --git a/backend/src/db/schemas/certificate-templates-v2.ts b/backend/src/db/schemas/certificate-templates-v2.ts index 68c3c3ca4..08977988a 100644 --- a/backend/src/db/schemas/certificate-templates-v2.ts +++ b/backend/src/db/schemas/certificate-templates-v2.ts @@ -10,15 +10,14 @@ import { TImmutableDBKeys } from "./models"; export const CertificateTemplatesV2Schema = z.object({ id: z.string().uuid(), projectId: z.string(), - slug: z.string(), + name: z.string(), description: z.string().nullable().optional(), - attributes: z.unknown().nullable().optional(), + subject: z.unknown().nullable().optional(), + sans: z.unknown().nullable().optional(), keyUsages: z.unknown().nullable().optional(), extendedKeyUsages: z.unknown().nullable().optional(), - subjectAlternativeNames: z.unknown().nullable().optional(), + algorithms: z.unknown().nullable().optional(), validity: z.unknown().nullable().optional(), - signatureAlgorithm: z.unknown().nullable().optional(), - keyAlgorithm: z.unknown().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 03605cc3c..589a79721 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,8 +11,8 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; -import { registerCertRouter } from "./certificate-router"; import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; +import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; 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 d13a9ad61..889961776 100644 --- a/backend/src/server/routes/v2/certificate-templates-v2-router.ts +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -1,110 +1,133 @@ import { z } from "zod"; -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 { - CertDurationUnit, CertExtendedKeyUsageType, - CertIncludeType, CertKeyUsageType, CertSubjectAlternativeNameType, CertSubjectAttributeType } from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(/^\d+[dhmy]$/, { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + }) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { - 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: "/", @@ -114,39 +137,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - 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)" - } - ), + body: createCertificateTemplateV2Schema, response: { 200: z.object({ - certificateTemplate: CertificateTemplatesV2Schema + certificateTemplate: certificateTemplateV2ResponseSchema }) } }, @@ -169,7 +163,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro type: EventType.CREATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.slug, + name: certificateTemplate.name, projectId: certificateTemplate.projectId } } @@ -196,7 +190,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro }), response: { 200: z.object({ - certificateTemplates: CertificateTemplatesV2Schema.array(), + certificateTemplates: certificateTemplateV2ResponseSchema.array(), totalCount: z.number() }) } @@ -240,7 +234,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro }), response: { 200: z.object({ - certificateTemplate: CertificateTemplatesV2Schema + certificateTemplate: certificateTemplateV2ResponseSchema }) } }, @@ -261,7 +255,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro type: EventType.GET_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.slug + name: certificateTemplate.name } } }); @@ -282,20 +276,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro 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() - }), + body: updateCertificateTemplateV2Schema, response: { 200: z.object({ - certificateTemplate: CertificateTemplatesV2Schema + certificateTemplate: certificateTemplateV2ResponseSchema }) } }, @@ -317,7 +301,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.slug + name: certificateTemplate.name } } }); @@ -340,7 +324,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro }), response: { 200: z.object({ - certificateTemplate: CertificateTemplatesV2Schema + certificateTemplate: certificateTemplateV2ResponseSchema }) } }, @@ -361,7 +345,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro type: EventType.DELETE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.slug + name: certificateTemplate.name } } }); diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts index bd42d05e7..d7ac82c16 100644 --- a/backend/src/services/certificate-common/certificate-constants.ts +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -32,6 +32,17 @@ export enum CertIncludeType { PROHIBIT = "prohibit" } +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + export enum CertDurationUnit { DAYS = "days", MONTHS = "months", @@ -39,7 +50,9 @@ export enum CertDurationUnit { } export enum CertSubjectAttributeType { - COMMON_NAME = "common_name" + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" } export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => { @@ -184,3 +197,5 @@ export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType); export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts index 66aaee4df..231216b86 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -50,43 +50,27 @@ export const buildCertificateSubjectFromTemplate = ( request: Record, templateAttributes?: Array<{ type: string; - include: "mandatory" | "optional" | "prohibit"; - value?: string[]; + allowed?: string[]; + required?: string[]; + denied?: string[]; }> ): Record => { const subject: Record = {}; const attributeMap: Record = { - common_name: "commonName" + common_name: "commonName", + organization: "organization", + country: "country" }; if (!templateAttributes || templateAttributes.length === 0) { - throw new Error( - "Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints." - ); + return subject; } - 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; - } - const requestKey = attributeMap[attr.type]; const value = request[requestKey]; - if (value && typeof value === "string") { + if (value && typeof value === "string" && (attr.allowed || attr.required)) { subject[attr.type] = value; } }); @@ -98,8 +82,9 @@ export const buildSubjectAlternativeNamesFromTemplate = ( request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, templateSans?: Array<{ type: string; - include: "mandatory" | "optional" | "prohibit"; - value?: string[]; + allowed?: string[]; + required?: string[]; + denied?: string[]; }> ): string => { if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { @@ -107,33 +92,13 @@ export const buildSubjectAlternativeNamesFromTemplate = ( } if (!templateSans || templateSans.length === 0) { - 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 ""; + return request.subjectAlternativeNames.map((san) => san.value).join(","); } - 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)) { - allowedSans.push(san.value); - } + allowedSans.push(san.value); }); return allowedSans.join(","); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index e8892c20c..6b2852c6b 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -68,7 +68,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { (tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"), (tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"), (tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"), - (tx || db).ref("slug").withSchema(TableName.CertificateTemplateV2).as("templateName"), + (tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"), (tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"), (tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), (tx || db) 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 eb4f5b794..08a98a3b8 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 @@ -23,19 +23,12 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { const serialized = { ...data } as Record; - const jsonFields = [ - "attributes", - "keyUsages", - "extendedKeyUsages", - "subjectAlternativeNames", - "validity", - "signatureAlgorithm", - "keyAlgorithm" - ]; + + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; jsonFields.forEach((field) => { - const value = (data as Record)[field]; - if (value !== undefined) { + const value = serialized[field]; + if (value !== undefined && typeof value !== "string") { serialized[field] = JSON.stringify(value); } }); @@ -44,21 +37,15 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { }; const parseJsonFields = (raw: Record): TCertificateTemplateV2 => { - const jsonFields = [ - "attributes", - "keyUsages", - "extendedKeyUsages", - "subjectAlternativeNames", - "validity", - "signatureAlgorithm", - "keyAlgorithm" - ]; - const parsed = { ...raw }; + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + const parsed = { ...raw } as Record; jsonFields.forEach((field) => { const value = raw[field]; - if (value) { + if (value !== null && value !== undefined) { parsed[field] = typeof value === "string" ? JSON.parse(value) : value; + } else { + parsed[field] = undefined; } }); @@ -143,7 +130,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { if (search) { query = query.where((builder) => { - void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`); + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); }); } @@ -169,7 +156,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { if (search) { query = query.where((builder) => { - void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`); + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); }); } @@ -180,10 +167,10 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { } }; - const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => { + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { try { const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2) - .where({ slug, projectId }) + .where({ name, projectId }) .first(); if (!certificateTemplateV2) { @@ -192,7 +179,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { return parseJsonFields(certificateTemplateV2); } catch (error) { - throw new DatabaseError({ error, name: "Find certificate template v2 by slug and project id" }); + throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); } }; @@ -238,7 +225,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { findById, findByProjectId, countByProjectId, - findBySlugAndProjectId, + findByNameAndProjectId, isTemplateInUse, getProfilesUsingTemplate }; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts index b724b8469..29432620e 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,166 +1,123 @@ import { z } from "zod"; -import { slugSchema } from "@app/server/lib/schemas"; import { - CertDurationUnit, CertExtendedKeyUsageType, - CertIncludeType, CertKeyUsageType, CertSubjectAlternativeNameType, CertSubjectAttributeType } from "@app/services/certificate-common/certificate-constants"; const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); - -const includeTypeSchema = z.nativeEnum(CertIncludeType); - const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); -const durationUnitSchema = z.nativeEnum(CertDurationUnit); - -export const templateV2AttributeSchema = z +const templateV2SubjectSchema = z .object({ type: attributeTypeSchema, - include: includeTypeSchema, - value: z.array(z.string()).optional() + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() }) .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)) { + if (!data.allowed && !data.required && !data.denied) { return false; } return true; }, { - message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)" + message: "Subject attribute must have at least one allowed, required, or denied value" } ); -export 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 templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); -export 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 templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); -export const templateV2SanSchema = z +const templateV2SanSchema = z .object({ type: sanTypeSchema, - include: includeTypeSchema, - value: z.array(z.string()).optional() + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() }) .refine( (data) => { - if (data.include === "mandatory" && (!data.value || data.value.length > 1)) { + if (!data.allowed && !data.required && !data.denied) { return false; } return true; }, { - message: "Mandatory SANs can only have one value or no value (empty)" + message: "SAN must have at least one allowed, required, or denied value" } ); -export const templateV2ValiditySchema = z.object({ - maxDuration: z.object({ - value: z.number().positive(), - unit: durationUnitSchema - }), - minDuration: z - .object({ - value: z.number().positive(), - unit: durationUnitSchema +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(/^\d+[dhmy]$/, { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" }) .optional() }); -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() - }) - .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: slugSchema({ min: 1, max: 255 }), - description: z.string().max(1000).optional(), - attributes: z.array(templateV2AttributeSchema).min(1), - keyUsages: templateV2KeyUsagesSchema, - extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), - subjectAlternativeNames: z.array(templateV2SanSchema).optional(), - validity: templateV2ValiditySchema.optional(), - signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(), - keyAlgorithm: templateV2KeyAlgorithmSchema.optional() +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() }); -export const updateCertificateTemplateV2Schema = z.object({ - slug: slugSchema({ min: 1, max: 255 }).optional(), - description: z.string().max(1000).optional(), - attributes: z.array(templateV2AttributeSchema).optional(), +export const certificateTemplateV2ResponseSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), keyUsages: templateV2KeyUsagesSchema.optional(), extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), - subjectAlternativeNames: z.array(templateV2SanSchema).optional(), + algorithms: templateV2AlgorithmsSchema.optional(), validity: templateV2ValiditySchema.optional(), - signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(), - keyAlgorithm: templateV2KeyAlgorithmSchema.optional() -}); - -export const getCertificateTemplateV2ByIdSchema = z.object({ - id: z.string().uuid() -}); - -export const getCertificateTemplateV2BySlugSchema = z.object({ - projectId: z.string().min(1), - slug: slugSchema() -}); - -export const listCertificateTemplatesV2Schema = 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() -}); - -export const deleteCertificateTemplateV2Schema = z.object({ - id: z.string().uuid() + createdAt: z.date(), + updatedAt: z.date() }); export const certificateRequestSchema = z.object({ commonName: z.string().optional(), + organization: z.string().optional(), + organizationName: z.string().optional(), + country: z.string().optional(), keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), subjectAlternativeNames: z 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 333c13fbf..351c05830 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 @@ -12,9 +12,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; import { - CertDurationUnit, CertExtendedKeyUsageType, - CertIncludeType, CertKeyUsageType, CertSubjectAlternativeNameType, CertSubjectAttributeType @@ -31,6 +29,12 @@ import { TTemplateV2Policy } from "./certificate-template-v2-types"; +enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + describe("CertificateTemplateV2Service", () => { let service: TCertificateTemplateV2ServiceFactory; @@ -65,51 +69,50 @@ describe("CertificateTemplateV2Service", () => { }; const samplePolicy: TTemplateV2Policy = { - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY, - value: ["example.com"] + allowed: ["*.example.com", "example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example Inc", "Example Corp"], + denied: ["Malicious Corp"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com", "*.api.example.com"], + required: ["api.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["admin@example.com"], + denied: ["blocked@example.com"] } ], keyUsages: { - requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] }, - optionalUsages: { all: [CertKeyUsageType.DATA_ENCIPHERMENT] } + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [CertKeyUsageType.DATA_ENCIPHERMENT] }, extendedKeyUsages: { - requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, - optionalUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH] } + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [CertExtendedKeyUsageType.CLIENT_AUTH] }, - subjectAlternativeNames: [ - { - type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL, - value: ["example.com", "*.example.com"] - }, - { - type: CertSubjectAlternativeNameType.IP_ADDRESS, - include: CertIncludeType.MANDATORY, - value: ["192.168.1.1"] - } - ], validity: { - maxDuration: { value: 90, unit: CertDurationUnit.DAYS }, - minDuration: { value: 1, unit: CertDurationUnit.DAYS } + max: "90d" }, - signatureAlgorithm: { - allowedAlgorithms: ["SHA256-RSA", "SHA256-ECDSA"], - defaultAlgorithm: "SHA256-RSA" - }, - keyAlgorithm: { - allowedKeyTypes: ["RSA-2048", "RSA-4096", "ECDSA-P256"], - defaultKeyType: "RSA-2048" + algorithms: { + signature: ["SHA256-RSA", "SHA256-ECDSA"], + keyAlgorithm: ["RSA-2048", "RSA-4096", "ECDSA-P256"] } }; const sampleTemplate: TCertificateTemplateV2 = { id: "template-123", projectId: "project-123", - slug: "web-server-template", + name: "web-server-template", description: "Template for web server certificates", ...samplePolicy, createdAt: new Date(), @@ -160,7 +163,7 @@ describe("CertificateTemplateV2Service", () => { describe("createTemplateV2", () => { const createData: Omit = { - slug: "test-template", + name: "test-template", description: "Test description", ...samplePolicy }; @@ -176,15 +179,12 @@ describe("CertificateTemplateV2Service", () => { expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({ ...createData, - projectId: "project-123" + projectId: "project-123", + name: expect.any(String) }); expect(result).toEqual(sampleTemplate); }); - // 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) @@ -194,7 +194,7 @@ describe("CertificateTemplateV2Service", () => { describe("updateTemplateV2", () => { it("should update template with valid data", async () => { - const updateData = { slug: "updated-template-name" }; + const updateData = { name: "updated-template-name" }; const updatedTemplate = { ...sampleTemplate, ...updateData }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); @@ -207,7 +207,10 @@ describe("CertificateTemplateV2Service", () => { }); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); - expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", updateData); + expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", { + ...updateData, + name: expect.any(String) + }); expect(result).toEqual(updatedTemplate); }); @@ -218,12 +221,10 @@ describe("CertificateTemplateV2Service", () => { service.updateTemplateV2({ ...mockActor, templateId: "nonexistent-template", - data: { slug: "updated-name" } + data: { name: "updated-name" } }) ).rejects.toThrow(NotFoundError); }); - - // NOTE: Policy validation test removed as validation moved to schema level }); describe("getTemplateV2ById", () => { @@ -353,13 +354,13 @@ describe("CertificateTemplateV2Service", () => { describe("validateCertificateRequest", () => { const validRequest: TCertificateRequest = { - commonName: "example.com", + commonName: "api.example.com", + organization: "Example Inc", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], subjectAlternativeNames: [ - { type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }, - { type: CertSubjectAlternativeNameType.DNS_NAME, value: "*.example.com" }, - { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.1" } + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } ], validity: { ttl: "30d" }, signatureAlgorithm: "RSA-SHA256", @@ -386,22 +387,44 @@ describe("CertificateTemplateV2Service", () => { ); }); - it("should detect missing mandatory attributes", async () => { - const invalidRequest = { ...validRequest, commonName: undefined }; + it("should validate allowed attribute values against pattern", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); - const result = await service.validateCertificateRequest("template-123", invalidRequest); - - expect(result.isValid).toBe(false); - expect(result.errors).toContain("common_name is mandatory but not provided in request"); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); }); - it("should validate attribute values against allowed list", async () => { + it("should detect attribute values that don't match allowed patterns", async () => { const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; const result = await service.validateCertificateRequest("template-123", invalidRequest); expect(result.isValid).toBe(false); - expect(result.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); + expect(result.errors).toContain( + "common_name value 'forbidden.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should detect denied attribute values", async () => { + const templateWithDeny = { + ...sampleTemplate, + subject: [ + ...sampleTemplate.subject!, + { + type: CertSubjectAttributeType.ORGANIZATION, + denied: ["Forbidden Corp"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDeny); + + const invalidRequest = { ...validRequest, organization: "Forbidden Corp" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("organization value 'Forbidden Corp' is denied by template policy"); }); it("should detect missing required key usages", async () => { @@ -446,21 +469,24 @@ describe("CertificateTemplateV2Service", () => { expect(result.errors).toContain("Invalid extended key usages: invalid_eku"); }); - it("should detect missing mandatory SAN entries", async () => { - const invalidRequest = { ...validRequest, subjectAlternativeNames: [] }; + it("should detect missing required SAN entries", async () => { + const invalidRequest = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; const result = await service.validateCertificateRequest("template-123", invalidRequest); expect(result.isValid).toBe(false); - expect(result.errors).toContain("ip_address SAN is mandatory but not provided in request"); + expect(result.errors).toContain("Required email SAN matching pattern 'admin@example.com' not found in request"); }); - it("should validate SAN values against allowed list", async () => { + it("should validate SAN values against allowed patterns", async () => { const invalidRequest: TCertificateRequest = { ...validRequest, subjectAlternativeNames: [ { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, - { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.1" } + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } ] }; @@ -468,10 +494,38 @@ describe("CertificateTemplateV2Service", () => { expect(result.isValid).toBe(false); expect(result.errors).toContain( - "dns_name SAN value 'forbidden.com' does not match allowed patterns: example.com, *.example.com" + "dns_name SAN value 'forbidden.com' does not match allowed patterns: *.example.com, *.api.example.com" ); }); + it("should detect denied SAN values", async () => { + const templateWithDenySan = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["forbidden@example.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDenySan); + + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "forbidden@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'forbidden@example.com' found in request"); + }); + it("should detect invalid signature algorithm", async () => { const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" }; @@ -499,23 +553,22 @@ describe("CertificateTemplateV2Service", () => { expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); }); - it("should detect TTL below minimum duration", async () => { - const templateWithMinDuration = { + it("should detect TTL exceeding maximum duration", async () => { + const templateWithMaxDuration = { ...sampleTemplate, validity: { - maxDuration: { value: 90, unit: CertDurationUnit.DAYS as const }, - minDuration: { value: 7, unit: CertDurationUnit.DAYS as const } + max: "90d" } }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMinDuration); + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMaxDuration); - const invalidRequest = { ...validRequest, validity: { ttl: "1d" } }; + const invalidRequest = { ...validRequest, validity: { ttl: "100d" } }; const result = await service.validateCertificateRequest("template-123", invalidRequest); expect(result.isValid).toBe(false); - expect(result.errors).toContain("Requested validity period is below minimum required duration"); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); }); it("should handle various TTL formats", async () => { @@ -591,22 +644,28 @@ describe("CertificateTemplateV2Service", () => { expect(result.errors).toHaveLength(0); }); - it("should validate wildcard patterns in optional attributes", async () => { + it("should validate wildcard patterns in allow attributes", async () => { const wildcardTemplate = { ...sampleTemplate, attributes: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.example.com", "*.test.com"] + rule: CertAttributeRule.ALLOW, + value: "*.example.com" } ] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); const requestWithWildcard = { - ...validRequest, - commonName: "api.example.com" + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } }; const result = await service.validateCertificateRequest("template-123", requestWithWildcard); @@ -619,59 +678,70 @@ describe("CertificateTemplateV2Service", () => { attributes: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.example.com"] + rule: CertAttributeRule.ALLOW, + value: "*.example.com" } ] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); const requestWithNonMatchingWildcard = { - ...validRequest, - commonName: "api.notexample.com" + commonName: "api.notexample.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } }; const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard); expect(result.isValid).toBe(false); expect(result.errors).toContain( - "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com" + "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com, example.com" ); }); - it("should allow empty mandatory attributes when no value specified", async () => { - const emptyMandatoryTemplate = { + it("should require attribute value when allow rule exists", async () => { + const emptyAllowTemplate = { ...sampleTemplate, attributes: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: undefined + rule: CertAttributeRule.ALLOW, + value: "example.com" } ] }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyMandatoryTemplate); + mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyAllowTemplate); const requestWithoutCommonName = { - ...validRequest, - commonName: undefined + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } }; const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName); - expect(result.isValid).toBe(false); - expect(result.errors).toContain("common_name is mandatory but not provided in request"); + expect(result.isValid).toBe(true); }); - it("should prevent certificates from including prohibited SANs", async () => { - const prohibitTemplate = { + it("should prevent certificates from including denied SANs", async () => { + const denyTemplate = { ...sampleTemplate, - subjectAlternativeNames: [ + sans: [ + ...sampleTemplate.sans!, { - type: CertSubjectAlternativeNameType.EMAIL as const, - include: CertIncludeType.PROHIBIT as const + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*@example.com"] } ] }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(prohibitTemplate); + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); const requestWithProhibitedSan = { ...validRequest, @@ -680,79 +750,70 @@ describe("CertificateTemplateV2Service", () => { const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); expect(result.isValid).toBe(false); - expect(result.errors).toContain("email SAN is prohibited by template policy"); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); }); describe("comprehensive template validation scenarios", () => { it("should handle template with minimal required fields only", async () => { const minimalTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const + allowed: ["*"] } ], keyUsages: { - requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE] }, - optionalUsages: { all: [] } + required: [CertKeyUsageType.DIGITAL_SIGNATURE] }, extendedKeyUsages: { - requiredUsages: { all: [] }, - optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] } + allowed: [CertExtendedKeyUsageType.SERVER_AUTH] }, - subjectAlternativeNames: [], + sans: [], validity: { - maxDuration: { value: 30, unit: CertDurationUnit.DAYS as const } + max: "30d" }, - signatureAlgorithm: undefined, - keyAlgorithm: undefined + algorithms: undefined }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate); - const minimalRequest = { + const minimalReq = { commonName: "example.com", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], validity: { ttl: "15d" } }; - const result = await service.validateCertificateRequest("template-123", minimalRequest); + const result = await service.validateCertificateRequest("template-123", minimalReq); expect(result.isValid).toBe(true); }); - it("should handle template with all fields set to optional", async () => { - const optionalTemplate = { + it("should handle template with all fields set to allow", async () => { + const allowTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL as const + allowed: ["*"] }, { - type: "organization_name", - include: CertIncludeType.OPTIONAL as const - }, - { - type: "locality", - include: CertIncludeType.OPTIONAL as const + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["*"] } ], keyUsages: { - requiredUsages: { all: [] }, - optionalUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] } + allowed: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] }, extendedKeyUsages: { - requiredUsages: { all: [] }, - optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] } + allowed: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] }, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const + allowed: ["*"] } ] }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(optionalTemplate); + mockCertificateTemplateV2DAL.findById.mockResolvedValue(allowTemplate); const emptyRequest = { validity: { ttl: "30d" } @@ -762,36 +823,33 @@ describe("CertificateTemplateV2Service", () => { expect(result.isValid).toBe(true); }); - it("should handle template with SAN fields prohibited", async () => { - const prohibitTemplate = { + it("should handle template with SAN fields denied", async () => { + const denyTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["example.com"] + allowed: ["example.com"] } ], keyUsages: { - requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE] }, - optionalUsages: { all: [] } + required: [CertKeyUsageType.DIGITAL_SIGNATURE] }, extendedKeyUsages: { - requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, - optionalUsages: { all: [] } + required: [CertExtendedKeyUsageType.SERVER_AUTH] }, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.EMAIL, - include: CertIncludeType.PROHIBIT as const + denied: ["*"] }, { type: CertSubjectAlternativeNameType.URI, - include: CertIncludeType.PROHIBIT as const + denied: ["*"] } ] }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(prohibitTemplate); + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); const requestWithProhibited = { commonName: "example.com", @@ -806,25 +864,23 @@ describe("CertificateTemplateV2Service", () => { const result = await service.validateCertificateRequest("template-123", requestWithProhibited); expect(result.isValid).toBe(false); - expect(result.errors).toContain("email SAN is prohibited by template policy"); - expect(result.errors).toContain("uri SAN is prohibited by template policy"); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + expect(result.errors).toContain("uri SAN matching denied pattern 'https://example.com' found in request"); }); it("should validate complex attribute value constraints", async () => { const constrainedTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["example.com", "test.com"] + allowed: ["example.com", "test.com"] } ], - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["example.com", "*.example.com"] + allowed: ["*.example.com"] } ] }; @@ -855,33 +911,30 @@ describe("CertificateTemplateV2Service", () => { it("should validate SAN value constraints with multiple types", async () => { const sanTemplate = { ...sampleTemplate, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["example.com", "test.com"] + required: ["*.example.com"] }, { type: CertSubjectAlternativeNameType.IP_ADDRESS, - include: CertIncludeType.OPTIONAL as const, - value: ["192.168.1.1", "10.0.0.1"] + allowed: ["192.168.1.*"] }, { type: CertSubjectAlternativeNameType.EMAIL, - include: CertIncludeType.MANDATORY as const, - value: ["admin@example.com", "test@example.com"] + required: ["*@example.com"] } ] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); const validSanRequest = { - commonName: "example.com", + commonName: "api.example.com", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], subjectAlternativeNames: [ - { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "example.com" }, - { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }, + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.100" }, { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } ], validity: { ttl: "30d" } @@ -891,34 +944,46 @@ describe("CertificateTemplateV2Service", () => { expect(validResult.isValid).toBe(true); const missingSanRequest = { - commonName: "example.com", + commonName: "api.example.com", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], - subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "example.com" }], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], validity: { ttl: "30d" } }; const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest); expect(missingResult.isValid).toBe(false); - expect(missingResult.errors).toContain("email SAN is mandatory but not provided in request"); + expect(missingResult.errors).toContain( + "Required email SAN matching pattern '*@example.com' not found in request" + ); }); it("should validate key usage combinations thoroughly", async () => { const keyUsageTemplate = { ...sampleTemplate, keyUsages: { - requiredUsages: { all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] }, - optionalUsages: { all: [CertKeyUsageType.DATA_ENCIPHERMENT, CertKeyUsageType.KEY_AGREEMENT] } + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] }, extendedKeyUsages: { - requiredUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH] }, - optionalUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.EMAIL_PROTECTION] } + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] }, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["example.com", "*.example.com"] + allowed: ["*.example.com"] } ] }; @@ -963,19 +1028,14 @@ describe("CertificateTemplateV2Service", () => { it("should validate algorithm constraints thoroughly", async () => { const algorithmTemplate = { ...sampleTemplate, - signatureAlgorithm: { - allowedAlgorithms: ["SHA256-RSA", "SHA512-RSA"], - defaultAlgorithm: "SHA256-RSA" + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512"], + keyAlgorithm: ["RSA-2048", "RSA-4096"] }, - keyAlgorithm: { - allowedKeyTypes: ["RSA-2048", "RSA-4096"], - defaultKeyType: "RSA-2048" - }, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["example.com", "*.example.com"] + allowed: ["*"] } ] }; @@ -1026,14 +1086,12 @@ describe("CertificateTemplateV2Service", () => { const validityTemplate = { ...sampleTemplate, validity: { - maxDuration: { value: 365, unit: CertDurationUnit.DAYS as const }, - minDuration: { value: 1, unit: CertDurationUnit.DAYS as const } + max: "365d" }, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["example.com", "*.example.com"] + allowed: ["*"] } ] }; @@ -1043,7 +1101,7 @@ describe("CertificateTemplateV2Service", () => { { ttl: "1d", shouldBeValid: true, description: "minimum duration" }, { ttl: "365d", shouldBeValid: true, description: "maximum duration" }, { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" }, - { ttl: "23h", shouldBeValid: false, description: "below minimum" }, + { ttl: "23h", shouldBeValid: true, description: "valid duration under max" }, { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" }, { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" }, { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" }, @@ -1072,10 +1130,10 @@ describe("CertificateTemplateV2Service", () => { it("should reject requests with unlisted subject attributes", async () => { const templateWithLimitedAttributes = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const + allowed: ["*"] } ] }; @@ -1096,10 +1154,10 @@ describe("CertificateTemplateV2Service", () => { it("should reject requests with unlisted SAN types", async () => { const templateWithLimitedSans = { ...sampleTemplate, - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const + allowed: ["*"] } ] }; @@ -1141,8 +1199,7 @@ describe("CertificateTemplateV2Service", () => { it("should reject requests with algorithms when template doesn't define any", async () => { const templateWithoutAlgorithms = { ...sampleTemplate, - signatureAlgorithm: undefined, - keyAlgorithm: undefined + algorithms: undefined }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); @@ -1170,18 +1227,16 @@ describe("CertificateTemplateV2Service", () => { it("should validate all subject attribute types", async () => { const comprehensiveTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["example.com", "*.example.com"] + required: ["*"] } ], - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["example.com", "*.example.com"] + allowed: ["*"] } ] }; @@ -1208,7 +1263,7 @@ describe("CertificateTemplateV2Service", () => { missingCommonNameRequest ); expect(missingCommonNameResult.isValid).toBe(false); - expect(missingCommonNameResult.errors).toContain("common_name is mandatory but not provided in request"); + expect(missingCommonNameResult.errors).toContain("Missing required common_name attribute"); }); }); @@ -1216,14 +1271,13 @@ describe("CertificateTemplateV2Service", () => { it("should handle complex wildcard patterns", async () => { const wildcardTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["*.api.example.com", "service-*.internal.com", "exact-match.com"] + allowed: ["v1.api.example.com", "service-auth.internal.com", "exact-match.com"] } ], - subjectAlternativeNames: [] // Remove SAN requirements for this test + sans: [] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); @@ -1250,7 +1304,11 @@ describe("CertificateTemplateV2Service", () => { expect(result.isValid).toBe(testCase.shouldBeValid); if (!testCase.shouldBeValid) { - expect(result.errors.some((error) => error.includes("does not match allowed patterns"))).toBe(true); + expect( + result.errors.some( + (error) => error.includes("does not match allowed patterns") || error.includes("not in allowed values") + ) + ).toBe(true); } } }); @@ -1258,14 +1316,13 @@ describe("CertificateTemplateV2Service", () => { it("should handle special regex characters in wildcard patterns", async () => { const specialCharTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.MANDATORY as const, - value: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + allowed: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] } ], - subjectAlternativeNames: [] // Remove SAN requirements for this test + sans: [] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(specialCharTemplate); @@ -1295,14 +1352,16 @@ describe("CertificateTemplateV2Service", () => { it("should validate signature algorithm constraints", async () => { const algorithmTemplate = { ...sampleTemplate, - signatureAlgorithm: { - allowedAlgorithms: ["SHA256-RSA", "SHA512-RSA", "SHA256-ECDSA"], - defaultAlgorithm: "SHA256-RSA" + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], + keyAlgorithm: ["RSA_2048", "RSA_4096", "EC_prime256v1"] }, - keyAlgorithm: { - allowedKeyTypes: ["RSA-2048", "RSA-4096", "ECDSA-P256"], - defaultKeyType: "RSA-2048" - } + sans: [ + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["*"] + } + ] }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); @@ -1359,7 +1418,6 @@ describe("CertificateTemplateV2Service", () => { }; const result = await service.validateCertificateRequest("template-123", request); - expect(result.isValid).toBe(testCase.shouldBeValid); if (!testCase.shouldBeValid) { @@ -1374,8 +1432,8 @@ describe("CertificateTemplateV2Service", () => { it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { const templateWithoutAlgorithms = { ...sampleTemplate, - signatureAlgorithm: undefined, - keyAlgorithm: undefined + algorithms: undefined, + sans: undefined }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); @@ -1383,9 +1441,7 @@ describe("CertificateTemplateV2Service", () => { 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" }] - // No algorithms specified - should be valid + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] }; const result = await service.validateCertificateRequest("template-123", request); @@ -1396,8 +1452,7 @@ describe("CertificateTemplateV2Service", () => { it("should reject algorithms when template has no algorithm constraints", async () => { const templateWithoutAlgorithms = { ...sampleTemplate, - signatureAlgorithm: undefined, - keyAlgorithm: undefined + algorithms: undefined }; mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); @@ -1424,28 +1479,16 @@ describe("CertificateTemplateV2Service", () => { it("should allow requests that match any of multiple attribute policies of same type", async () => { const multipleAttributePoliciesTemplate = { ...sampleTemplate, - attributes: [ + subject: [ { type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.infisical.com"] - }, - { - type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.infisical2.com"] + allowed: ["*.infisical.com", "*.infisical2.com"] } ], - subjectAlternativeNames: [ + sans: [ { type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.infisical.com"] - }, - { - type: CertSubjectAlternativeNameType.DNS_NAME, - include: CertIncludeType.OPTIONAL as const, - value: ["*.infisical2.com"] + allowed: ["*.infisical.com", "*.infisical2.com"] } ] }; @@ -1497,5 +1540,202 @@ describe("CertificateTemplateV2Service", () => { ); }); }); + + describe("New validation logic with allow/deny/require", () => { + it("should validate complex attribute value constraints", async () => { + const complexTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example*"] + }, + { + type: CertSubjectAttributeType.COUNTRY, + denied: ["XX"] + } + ], + sans: [] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(complexTemplate); + + const validComplexRequest = { + commonName: "api.example.com", + organization: "Example Corp", + country: "US", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validComplexRequest); + expect(result1.isValid).toBe(true); + + const invalidCountryRequest = { ...validComplexRequest, country: "XX" }; + const result2 = await service.validateCertificateRequest("template-123", invalidCountryRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("country value 'XX' is denied by template policy"); + const invalidOrgRequest = { ...validComplexRequest, organization: "Different Corp" }; + const result3 = await service.validateCertificateRequest("template-123", invalidOrgRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "organization value 'Different Corp' does not match allowed patterns: Example*" + ); + }); + + it("should handle SAN allow/deny/require logic", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + denied: ["192.168.1.*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validSanRequest); + expect(result1.isValid).toBe(true); + + const missingEmailRequest = { + ...validSanRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result2 = await service.validateCertificateRequest("template-123", missingEmailRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("Required email SAN matching pattern '*@example.com' not found in request"); + + const deniedIpRequest = { + ...validSanRequest, + subjectAlternativeNames: [ + ...validSanRequest.subjectAlternativeNames, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.100" } + ] + }; + + const result3 = await service.validateCertificateRequest("template-123", deniedIpRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain("ip_address SAN matching denied pattern '192.168.1.100' found in request"); + }); + + it("should validate wildcard patterns correctly", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.acme.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.api.acme.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + { cn: "api.acme.com", san: "v1.api.acme.com", shouldPass: true }, + { cn: "www.acme.com", san: "beta.api.acme.com", shouldPass: true }, + { cn: "acme.com", san: "api.acme.com", shouldPass: false }, // Missing subdomain + { cn: "api.notacme.com", san: "v1.api.acme.com", shouldPass: false }, // Wrong domain + { cn: "api.acme.com", san: "api.acme.com", shouldPass: false } // SAN missing required subdomain + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.cn, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: testCase.san }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldPass); + } + }); + + it("should enforce multiple required SAN types", async () => { + const multiRequiredTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.URI, + required: ["https://*.example.com/*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multiRequiredTemplate); + + const completeRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" }, + { type: CertSubjectAlternativeNameType.URI, value: "https://api.example.com/webhook" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", completeRequest); + expect(result1.isValid).toBe(true); + + const incompleteRequest = { + ...completeRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result2 = await service.validateCertificateRequest("template-123", incompleteRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain( + "Required uri SAN matching pattern 'https://*.example.com/*' not found in request" + ); + }); + }); }); }); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts index fa09215e6..61644215f 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,7 +12,7 @@ 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 { CertSubjectAttributeType } from "../certificate-common/certificate-constants"; import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; import { TCertificateRequest, @@ -57,75 +57,86 @@ export const certificateTemplateV2ServiceFactory = ({ } }; - const convertToMilliseconds = (value: number, unit: "days" | "months" | "years"): number => { - switch (unit) { - case "days": - return value * 24 * 60 * 60 * 1000; - case "months": - return value * 30 * 24 * 60 * 60 * 1000; - case "years": - return value * 365 * 24 * 60 * 60 * 1000; - default: - throw new Error(`Unsupported duration unit: ${unit as string}`); - } - }; + const validateSubjectAttributePolicy = ( + subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!subject || subject.length === 0) return; - 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) { + // Validate each subject attribute policy + for (const attr of subject) { + // Ensure at least one field is provided + if (!attr.allowed && !attr.required && !attr.denied) { throw new ForbiddenRequestError({ - message: `Multiple mandatory values found for subject attribute type '${type}'. Only one mandatory value is allowed per attribute type.` + message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value` }); } - 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.` - }); + // Check for duplicate values within arrays + const arrays = [ + { name: "allowed", values: attr.allowed }, + { name: "required", values: attr.required }, + { name: "denied", values: attr.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'` + }); + } + } } } }; - const getRequestAttributeValue = ( - request: TCertificateRequest, - attrType: CertSubjectAttributeType | string - ): string | undefined => { - switch (attrType) { - case CertSubjectAttributeType.COMMON_NAME: - case "common_name": - return request.commonName; - default: - return undefined; + const validateSanPolicy = ( + sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!sans || sans.length === 0) return; + + // Validate each SAN policy + for (const san of sans) { + if (!san.allowed && !san.required && !san.denied) { + throw new ForbiddenRequestError({ + message: `SAN type '${san.type}' must have at least one allowed, required, or denied value` + }); + } + + const arrays = [ + { name: "allowed", values: san.allowed }, + { name: "required", values: san.required }, + { name: "denied", values: san.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for SAN type '${san.type}'` + }); + } + } + } } }; - const generateTemplateSlug = (baseSlug?: string): string => { - if (baseSlug) { - return slugify(baseSlug); + const generateTemplateSlug = (baseName?: string): string => { + if (baseName) { + return slugify(baseName); } return slugify(alphaNumericNanoId(12)); }; const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => { - const existingTemplate = await certificateTemplateV2DAL.findBySlugAndProjectId(desiredSlug, projectId); + const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId); if (!existingTemplate || (templateId && existingTemplate.id === templateId)) { return desiredSlug; } const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; - const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId); + const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId); if (!existingAlternative) { return alternativeSlug; } @@ -139,8 +150,12 @@ export const certificateTemplateV2ServiceFactory = ({ }; const createWildcardRegex = (pattern: string): RegExp => { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); - const regexPattern = escaped.replace(/\*/g, ".*"); + const wildcardRegex = new RE2(/\*/g); + const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__"); + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = withPlaceholder.replace(escapeRegex, "\\$&"); + const placeholderRegex = new RE2(/__WILDCARD__/g); + const regexPattern = escaped.replace(placeholderRegex, ".*"); return new RE2(`^${regexPattern}$`); }; @@ -166,6 +181,64 @@ export const certificateTemplateV2ServiceFactory = ({ return mapping[templateFormat] || templateFormat; }; + const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => { + if (!keyUsages) return; + + if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: keyUsages.allowed }, + { name: "required", values: keyUsages.required }, + { name: "denied", values: keyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} key usages list` + }); + } + } + } + }; + + const validateExtendedKeyUsagePolicy = (extendedKeyUsages: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }) => { + if (!extendedKeyUsages) return; + + if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Extended key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: extendedKeyUsages.allowed }, + { name: "required", values: extendedKeyUsages.required }, + { name: "denied", values: extendedKeyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} extended key usages list` + }); + } + } + } + }; + const validateValueAgainstConstraints = ( value: string, allowedValues: string[], @@ -184,7 +257,7 @@ export const certificateTemplateV2ServiceFactory = ({ if (regex.test(value)) { return { isValid: true }; } - } catch { + } catch (error) { if (allowedValue === value) { return { isValid: true }; } @@ -213,89 +286,186 @@ export const certificateTemplateV2ServiceFactory = ({ const errors: string[] = []; const warnings: string[] = []; - const templateAttributeTypes = new Set(template.attributes?.map((attr) => attr.type) || []); + // Validate subject attributes + const subjectPolicies = template.subject; + const requestAttributes = new Map(); + if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName); + if (request.organization || request.organizationName) { + requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization || request.organizationName!); + } + if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); - const attributePoliciesByType = new Map(); - template.attributes?.forEach((attrPolicy) => { - const existing = attributePoliciesByType.get(attrPolicy.type) || []; - attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]); - }); + if (subjectPolicies && subjectPolicies.length > 0) { + // Validate each template subject attribute policy + for (const attrPolicy of subjectPolicies) { + const requestValue = requestAttributes.get(attrPolicy.type); - 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 (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); + // Check denied values first + if (requestValue && attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + // Skip further validation for this attribute if it's denied + } else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + // Check allowed values if present and not denied + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + // Check allowed values if present and not denied + const allowedValidation = validateValueAgainstConstraints(requestValue, attrPolicy.allowed, attrPolicy.type); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); } } } - } - const requestAttributeTypes: CertSubjectAttributeType[] = []; - if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME); + // Check for required subject attributes + for (const attrPolicy of subjectPolicies) { + if (attrPolicy.required && attrPolicy.required.length > 0) { + const requestValue = requestAttributes.get(attrPolicy.type); + if (!requestValue) { + errors.push(`Missing required ${attrPolicy.type} attribute`); + } else { + // Validate that the request value matches at least one required pattern + const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + if (!hasMatchingRequired) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}` + ); + } + } + } + } - for (const requestAttrType of requestAttributeTypes) { - if (!templateAttributeTypes.has(requestAttrType)) { - errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`); + // Check if any request attributes are not covered by template policies + for (const [attrType] of requestAttributes) { + const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType); + if (!hasPolicy) { + errors.push(`${attrType} is not allowed by template policy (not defined in template)`); + } + } + } else if (requestAttributes.size > 0) { + // No subject policies defined but request has subject attributes - deny all + for (const [attrType] of requestAttributes) { + errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`); } } - 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) - ); + + // Validate Subject Alternative Names + const sansPolicies = template.sans; + if (sansPolicies && sansPolicies.length > 0) { + const requestSansByType = new Map(); + + // Group request SANs by type + if (request.subjectAlternativeNames) { + for (const san of request.subjectAlternativeNames) { + if (!requestSansByType.has(san.type)) { + requestSansByType.set(san.type, []); + } + requestSansByType.get(san.type)!.push(san.value); + } + } + + // Validate each SAN policy + for (const sanPolicy of sansPolicies) { + const requestSans = requestSansByType.get(sanPolicy.type) || []; + + // Check REQUIRED values - at least one SAN must match each required pattern + if (sanPolicy.required && sanPolicy.required.length > 0) { + for (const requiredValue of sanPolicy.required) { + const hasMatchingRequiredSan = requestSans.some((sanValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + + if (!hasMatchingRequiredSan) { + errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`); + } + } + } + + // Check DENIED values - no SAN should match denied patterns + if (sanPolicy.denied && sanPolicy.denied.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`); + if (validation.isValid) { + errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`); + } + } + } + + // Check ALLOWED values - if present, all SANs must match at least one allowed pattern + if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + } + } + } + + // Check if any request SANs are for types not covered by template policies + for (const [requestSanType] of requestSansByType) { + const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType); + if (!hasPolicy) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + } else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) { + // No SAN policies defined but request has SANs - deny all + for (const san of request.subjectAlternativeNames) { + errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`); + } + } + + // Validate key usages + const keyUsagePolicy = template.keyUsages; + if (keyUsagePolicy) { + // Check REQUIRED key usages - must have all required usages + if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) { + const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage)); if (missingRequired.length > 0) { errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); } } - if (request.keyUsages && (template.keyUsages.requiredUsages || template.keyUsages.optionalUsages)) { - const allAllowedUsages = [ - ...(template.keyUsages.requiredUsages?.all || []), - ...(template.keyUsages.optionalUsages?.all || []) - ]; + // Check DENIED key usages - must not have any denied usages + if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) { + const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage)); + if (deniedUsages.length > 0) { + errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`); + } + } - if (allAllowedUsages.length > 0) { - const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); - if (invalidUsages.length > 0) { - errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); - } + // Check ALLOWED key usages - if present, all usages must be in allowed list + if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; + const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); } } } else if (request.keyUsages && request.keyUsages.length > 0) { errors.push(`Key usages are not allowed by template policy (not defined in template)`); } - if (template.extendedKeyUsages) { - if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) { - const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter( + // Validate extended key usages + const extendedKeyUsagePolicy = template.extendedKeyUsages; + if (extendedKeyUsagePolicy) { + // Check REQUIRED extended key usages - must have all required usages + if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) { + const missingRequired = extendedKeyUsagePolicy.required.filter( (usage) => !request.extendedKeyUsages?.includes(usage) ); if (missingRequired.length > 0) { @@ -303,89 +473,46 @@ export const certificateTemplateV2ServiceFactory = ({ } } + // Check DENIED extended key usages - must not have any denied usages + if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) { + const deniedUsages = request.extendedKeyUsages.filter((usage) => + extendedKeyUsagePolicy?.denied?.includes(usage) + ); + if (deniedUsages.length > 0) { + errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED extended key usages - if present, all usages must be in allowed list if ( request.extendedKeyUsages && - (template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages) + extendedKeyUsagePolicy && + extendedKeyUsagePolicy.allowed && + extendedKeyUsagePolicy.allowed.length > 0 ) { - const allAllowedUsages = [ - ...(template.extendedKeyUsages.requiredUsages?.all || []), - ...(template.extendedKeyUsages.optionalUsages?.all || []) + const allAllowedExtendedUsages = [ + ...(extendedKeyUsagePolicy.required || []), + ...(extendedKeyUsagePolicy.allowed || []) ]; - - 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(", ")}`); - } + const invalidExtendedUsages = request.extendedKeyUsages.filter( + (usage) => !allAllowedExtendedUsages.includes(usage) + ); + if (invalidExtendedUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`); } } } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); } - const templateSanTypes = new Set(template.subjectAlternativeNames?.map((san) => san.type) || []); - - const sanPoliciesByType = new Map(); - template.subjectAlternativeNames?.forEach((sanPolicy) => { - const existing = sanPoliciesByType.get(sanPolicy.type) || []; - sanPoliciesByType.set(sanPolicy.type, [...existing, sanPolicy]); - }); - - 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); - } - }); - } - } - } - - 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)`); - } - } - + // Validate algorithms with new structure if (request.signatureAlgorithm) { - if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) { - const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map( - mapTemplateSignatureAlgorithmToApi - ); + if (template.algorithms?.signature && template.algorithms.signature.length > 0) { + const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi); if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); } - } else if (!template.signatureAlgorithm) { + } else if (!template.algorithms?.signature) { errors.push( `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` ); @@ -393,40 +520,19 @@ export const certificateTemplateV2ServiceFactory = ({ } if (request.keyAlgorithm) { - if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) { - const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi); + if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) { + const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi); if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); } - } else if (!template.keyAlgorithm) { + } else if (!template.algorithms?.keyAlgorithm) { errors.push( `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` ); } } - if (request.validity?.ttl && template.validity) { - const requestDuration = parseTTL(request.validity.ttl); - const maxDuration = convertToMilliseconds( - template.validity.maxDuration.value, - template.validity.maxDuration.unit - ); - - if (requestDuration > maxDuration) { - errors.push(`Requested validity period 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 is below minimum required duration`); - } - } - } - + // Validate validity with new structure if (request.validity?.ttl && (request.notBefore || request.notAfter)) { errors.push( "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." @@ -437,30 +543,32 @@ export const certificateTemplateV2ServiceFactory = ({ errors.push("notBefore must be earlier than notAfter"); } - if ((request.notBefore || request.notAfter) && template.validity) { + // Validate TTL against template validity constraints + if (request.validity?.ttl && template.validity) { + const requestDurationMs = parseTTL(request.validity.ttl); + + // Check maximum duration using max field + if (template.validity.max) { + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDurationMs > maxDurationMs) { + errors.push("Requested validity period exceeds maximum allowed duration"); + } + } + } + // Validate explicit date range against max duration + if ((request.notBefore || request.notAfter) && template.validity?.max) { const notBefore = request.notBefore || new Date(); const { notAfter } = request; if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { const requestDuration = notAfter.getTime() - notBefore.getTime(); + const maxDurationMs = parseTTL(template.validity.max); - 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 > maxDurationMs) { + errors.push( + `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}` ); - if (requestDuration < minDuration) { - errors.push(`Requested validity period (notBefore to notAfter) is below minimum required duration`); - } } } } @@ -505,16 +613,33 @@ export const certificateTemplateV2ServiceFactory = ({ throw new Error("Template data is required"); } - if (data.attributes) { - validateSubjectAttributePolicy(data.attributes); + if (data.subject) { + validateSubjectAttributePolicy(data.subject); } - const slug = data.slug || generateTemplateSlug(); + if (data.sans) { + validateSanPolicy(data.sans); + } + + if (data.keyUsages) { + validateKeyUsagePolicy(data.keyUsages); + } + + if (data.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(data.extendedKeyUsages); + } + + // Generate slug from name and ensure it's unique within project + if (!data.name) { + throw new ForbiddenRequestError({ message: "Template name is required" }); + } + + const slug = generateTemplateSlug(data.name); const uniqueSlug = await ensureUniqueSlug(projectId, slug); const template = await certificateTemplateV2DAL.create({ ...data, - slug: uniqueSlug, + name: uniqueSlug, projectId }); @@ -555,14 +680,29 @@ export const certificateTemplateV2ServiceFactory = ({ ProjectPermissionSub.CertificateTemplates ); - if (data.attributes) { - validateSubjectAttributePolicy(data.attributes); + if (data.subject) { + validateSubjectAttributePolicy(data.subject); + } + + if (data.sans) { + validateSanPolicy(data.sans); + } + + if (data.keyUsages) { + validateKeyUsagePolicy(data.keyUsages); + } + + if (data.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(data.extendedKeyUsages); } const updateData = { ...data }; - if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) { - const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId); - updateData.slug = uniqueSlug; + if (data.name && typeof data.name === "string") { + const newSlug = generateTemplateSlug(data.name); + if (newSlug !== existingTemplate.name) { + const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId); + updateData.name = uniqueSlug; + } } const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); @@ -636,7 +776,7 @@ export const certificateTemplateV2ServiceFactory = ({ ProjectPermissionSub.CertificateTemplates ); - const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId); + const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); if (!template) { throw new NotFoundError({ message: "Certificate template not found" }); } @@ -734,8 +874,8 @@ export const certificateTemplateV2ServiceFactory = ({ throw new ForbiddenRequestError({ 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.` + ? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.` + : `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` }); } 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 dd5cc9f27..a034f1937 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,101 +1,71 @@ 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<{ + subject?: Array<{ type: CertSubjectAttributeType; - include: CertIncludeType; - value?: string[]; + allowed?: string[]; + required?: string[]; + denied?: string[]; }>; - keyUsages: { - requiredUsages?: { all: CertKeyUsageType[] }; - optionalUsages?: { all: CertKeyUsageType[] }; - }; - extendedKeyUsages: { - requiredUsages?: { all: CertExtendedKeyUsageType[] }; - optionalUsages?: { all: CertExtendedKeyUsageType[] }; - }; - subjectAlternativeNames: Array<{ + sans?: Array<{ type: CertSubjectAlternativeNameType; - include: CertIncludeType; - value?: string[]; + allowed?: string[]; + required?: string[]; + denied?: string[]; }>; - validity: { - maxDuration: { value: number; unit: CertDurationUnit }; - minDuration?: { value: number; unit: CertDurationUnit }; + keyUsages?: { + allowed?: CertKeyUsageType[]; + required?: CertKeyUsageType[]; + denied?: CertKeyUsageType[]; }; - signatureAlgorithm: { - allowedAlgorithms: string[]; - defaultAlgorithm: string; + extendedKeyUsages?: { + allowed?: CertExtendedKeyUsageType[]; + required?: CertExtendedKeyUsageType[]; + denied?: CertExtendedKeyUsageType[]; }; - keyAlgorithm: { - allowedKeyTypes: string[]; - defaultKeyType: string; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; }; } -export type TCertificateTemplateV2 = Omit< - TCertificateTemplatesV2, - | "attributes" - | "keyUsages" - | "extendedKeyUsages" - | "subjectAlternativeNames" - | "validity" - | "signatureAlgorithm" - | "keyAlgorithm" -> & { - attributes: TTemplateV2Policy["attributes"]; - keyUsages: TTemplateV2Policy["keyUsages"]; - extendedKeyUsages: TTemplateV2Policy["extendedKeyUsages"]; - subjectAlternativeNames: TTemplateV2Policy["subjectAlternativeNames"]; - validity: TTemplateV2Policy["validity"]; - signatureAlgorithm: TTemplateV2Policy["signatureAlgorithm"]; - keyAlgorithm: TTemplateV2Policy["keyAlgorithm"]; -}; - -export type TCertificateTemplateV2Insert = Omit< - TCertificateTemplatesV2Insert, - | "attributes" - | "keyUsages" - | "extendedKeyUsages" - | "subjectAlternativeNames" - | "validity" - | "signatureAlgorithm" - | "keyAlgorithm" -> & { - attributes?: TTemplateV2Policy["attributes"]; +export type TCertificateTemplateV2 = TCertificateTemplatesV2 & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; keyUsages?: TTemplateV2Policy["keyUsages"]; extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; - subjectAlternativeNames?: TTemplateV2Policy["subjectAlternativeNames"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Insert = TCertificateTemplatesV2Insert & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; validity?: TTemplateV2Policy["validity"]; - signatureAlgorithm?: TTemplateV2Policy["signatureAlgorithm"]; - keyAlgorithm?: TTemplateV2Policy["keyAlgorithm"]; }; export type TCertificateTemplateV2Update = Partial< Pick< TCertificateTemplateV2, - | "slug" - | "description" - | "attributes" - | "keyUsages" - | "extendedKeyUsages" - | "subjectAlternativeNames" - | "validity" - | "signatureAlgorithm" - | "keyAlgorithm" + "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity" > >; export interface TCertificateRequest { commonName?: string; + organization?: string; organizationName?: string; organizationUnit?: string; locality?: string; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 831256de3..69f3dce8d 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -102,12 +102,12 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: const validateAlgorithmCompatibility = ( ca: TCertificateAuthorityWithAssociatedCa, template: { - signatureAlgorithm?: { - allowedAlgorithms?: string[]; + algorithms?: { + signature?: string[]; }; } ) => { - if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) { + if (!template.algorithms?.signature || template.algorithms.signature.length === 0) { return; } @@ -116,7 +116,7 @@ const validateAlgorithmCompatibility = ( throw new BadRequestError({ message: "CA key algorithm not found" }); } - const compatibleAlgorithms = template.signatureAlgorithm.allowedAlgorithms.filter((sigAlg: string) => { + const compatibleAlgorithms = template.algorithms.signature.filter((sigAlg: string) => { const parts = sigAlg.split("-"); const keyType = parts[parts.length - 1]; @@ -133,7 +133,7 @@ const validateAlgorithmCompatibility = ( if (compatibleAlgorithms.length === 0) { throw new BadRequestError({ - message: `Template signature algorithms (${template.signatureAlgorithm.allowedAlgorithms.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})` + message: `Template signature algorithms (${template.algorithms.signature.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})` }); } }; @@ -180,7 +180,10 @@ export const certificateV3ServiceFactory = ({ }); } - const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const mappedCertificateRequest = mapEnumsForValidation({ + ...certificateRequest, + subjectAlternativeNames: certificateRequest.altNames + }); const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, mappedCertificateRequest @@ -217,26 +220,25 @@ export const certificateV3ServiceFactory = ({ validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = - certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; - const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType; + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm; + const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm; - if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { throw new BadRequestError({ - message: "Key algorithm is required by template policy but not provided in request or template default" + message: "Key algorithm is required by template policy but not provided in request" }); } - if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) { + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { throw new BadRequestError({ - message: "Signature algorithm is required by template policy but not provided in request or template default" + message: "Signature algorithm is required by template policy but not provided in request" }); } - const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes); + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( { subjectAlternativeNames: certificateRequest.altNames }, - template.subjectAlternativeNames + template.sans ); const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = @@ -326,18 +328,18 @@ export const certificateV3ServiceFactory = ({ validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; - const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType; + const effectiveSignatureAlgorithm = signatureAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm; - if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { throw new BadRequestError({ - message: "Key algorithm is required by template policy but not provided in request or template default" + message: "Key algorithm is required by template policy but not provided in request" }); } - if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) { + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { throw new BadRequestError({ - message: "Signature algorithm is required by template policy but not provided in request or template default" + message: "Signature algorithm is required by template policy but not provided in request" }); } diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index baa9b4ed3..05a161c75 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,5 +1,4 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; -export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types"; export { useCreateCa, useCreateCertificate, @@ -24,3 +23,4 @@ export { useListCasByTypeAndProjectId, useListExternalCasByProjectId } from "./queries"; +export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types"; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index fb5952769..2768be2eb 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -123,78 +123,74 @@ export type TListCertificateTemplatesDTO = { }; export type TCertificateTemplateV2Policy = { - attributes: Array<{ - type: "common_name"; - include: "mandatory" | "optional" | "prohibit"; - value?: string[]; + subject?: Array<{ + type: "common_name" | "organization" | "country"; + allowed?: string[]; + required?: string[]; + denied?: string[]; }>; - keyUsages: { - requiredUsages: { all: string[] }; - optionalUsages: { all: string[] }; - }; - extendedKeyUsages: { - requiredUsages: { all: string[] }; - optionalUsages: { all: string[] }; - }; - subjectAlternativeNames: Array<{ + sans?: Array<{ type: "dns_name" | "ip_address" | "email" | "uri"; - include: "mandatory" | "optional" | "prohibit"; - value?: string[]; + allowed?: string[]; + required?: string[]; + denied?: string[]; }>; - validity: { - maxDuration: { value: number; unit: "days" | "months" | "years" }; - minDuration?: { value: number; unit: "days" | "months" | "years" }; + keyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; }; - signatureAlgorithm: { - allowedAlgorithms: string[]; - defaultAlgorithm: string; + extendedKeyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; }; - keyAlgorithm: { - allowedKeyTypes: string[]; - defaultKeyType: string; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; }; }; export type TCertificateTemplateV2New = { id: string; projectId: string; - slug: string; + name: string; description?: string; - attributes: any; - keyUsages: any; - extendedKeyUsages: any; - subjectAlternativeNames: any; - validity: any; - signatureAlgorithm: any; - keyAlgorithm: any; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; createdAt: string; updatedAt: string; }; export type TCreateCertificateTemplateV2NewDTO = { projectId: string; - slug: string; + name: string; description?: string; - attributes: TCertificateTemplateV2Policy["attributes"]; - keyUsages: TCertificateTemplateV2Policy["keyUsages"]; - extendedKeyUsages: TCertificateTemplateV2Policy["extendedKeyUsages"]; - subjectAlternativeNames: TCertificateTemplateV2Policy["subjectAlternativeNames"]; - validity: TCertificateTemplateV2Policy["validity"]; - signatureAlgorithm: TCertificateTemplateV2Policy["signatureAlgorithm"]; - keyAlgorithm: TCertificateTemplateV2Policy["keyAlgorithm"]; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; }; export type TUpdateCertificateTemplateV2NewDTO = { templateId: string; - slug?: string; + name?: string; description?: string; - attributes?: TCertificateTemplateV2Policy["attributes"]; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; - subjectAlternativeNames?: TCertificateTemplateV2Policy["subjectAlternativeNames"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; validity?: TCertificateTemplateV2Policy["validity"]; - signatureAlgorithm?: TCertificateTemplateV2Policy["signatureAlgorithm"]; - keyAlgorithm?: TCertificateTemplateV2Policy["keyAlgorithm"]; }; export type TDeleteCertificateTemplateV2NewDTO = { diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index bed78b434..a5c0d9e4a 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -53,10 +53,10 @@ export const PkiManagerLayout = () => { animate={{ x: 0 }} exit={{ x: -150 }} transition={{ duration: 0.2 }} - className="border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 dark w-full border-r md:w-60" + className="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60" > - -
+
{assumedPrivilegeDetails && }
-
+

{` ${t("common.no-mobile")} `} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 524072783..6530c890a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -1,9 +1,6 @@ -/* eslint-disable react/no-array-index-key */ -/* eslint-disable no-nested-ternary */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -35,13 +32,13 @@ import { KEY_USAGES_OPTIONS, SIGNATURE_ALGORITHMS_OPTIONS } from "@app/hooks/api/certificates/constants"; -import { - CertExtendedKeyUsage, - CertKeyAlgorithm, - CertKeyUsage -} from "@app/hooks/api/certificates/enums"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { + mapTemplateKeyAlgorithmToApi, + mapTemplateSignatureAlgorithmToApi +} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; import { CertificateContent } from "./CertificateContent"; @@ -109,9 +106,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const [certificateDetails, setCertificateDetails] = useState(null); const [allowedKeyUsages, setAllowedKeyUsages] = useState([]); const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState([]); + const [requiredKeyUsages, setRequiredKeyUsages] = useState([]); + const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState([]); const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState([]); const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState([]); const { currentProject } = useProject(); + const { data: cert } = useGetCert( (popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "" ); @@ -133,7 +133,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } = useForm({ resolver: zodResolver(schema), defaultValues: { - profileId: profileId ? profileId : "", + profileId: profileId || "", subjectAttributes: [{ type: "common_name", value: "" }], subjectAltNames: [], ttl: "30d", @@ -144,276 +144,122 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } }); + const resetAllState = useCallback(() => { + setCertificateDetails(null); + setAllowedKeyUsages([]); + setAllowedExtendedKeyUsages([]); + setRequiredKeyUsages([]); + setRequiredExtendedKeyUsages([]); + setAllowedSignatureAlgorithms([]); + setAllowedKeyAlgorithms([]); + reset(); + }, [reset]); + const selectedProfileId = watch("profileId"); - const selectedProfile = profilesData?.certificateProfiles?.find( - (p) => p.id === selectedProfileId + const selectedProfile = useMemo( + () => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId), + [profilesData?.certificateProfiles, selectedProfileId] ); const { data: templateData } = useGetCertificateTemplateV2ById({ templateId: selectedProfile?.certificateTemplateId || "" }); + const filteredKeyUsages = useMemo(() => { + if (allowedKeyUsages.length === 0) return KEY_USAGES_OPTIONS; + return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value)); + }, [allowedKeyUsages]); + + const filteredExtendedKeyUsages = useMemo(() => { + if (allowedExtendedKeyUsages.length === 0) return EXTENDED_KEY_USAGES_OPTIONS; + return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => + allowedExtendedKeyUsages.includes(value) + ); + }, [allowedExtendedKeyUsages]); + + const availableSignatureAlgorithms = useMemo(() => { + if (allowedSignatureAlgorithms.length === 0) { + return SIGNATURE_ALGORITHMS_OPTIONS; + } + return allowedSignatureAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [allowedSignatureAlgorithms]); + + const availableKeyAlgorithms = useMemo(() => { + if (allowedKeyAlgorithms.length === 0) { + return certKeyAlgorithms; + } + return allowedKeyAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [allowedKeyAlgorithms]); + useEffect(() => { - if (templateData && selectedProfile) { - if (templateData.signatureAlgorithm?.allowedAlgorithms && templateData.signatureAlgorithm.allowedAlgorithms.length > 0) { - const sigAlgMap: 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" - }; - - let defaultValue = templateData.signatureAlgorithm.defaultAlgorithm; - if (defaultValue && sigAlgMap[defaultValue]) { - defaultValue = sigAlgMap[defaultValue]; - } - - 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, - "RSA-4096": CertKeyAlgorithm.RSA_4096, - "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256, - "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384, - [CertKeyAlgorithm.ECDSA_P256]: CertKeyAlgorithm.ECDSA_P256, - [CertKeyAlgorithm.ECDSA_P384]: CertKeyAlgorithm.ECDSA_P384 - }; - - let defaultValue = templateData.keyAlgorithm.defaultKeyType; - if (defaultValue && keyAlgMap[defaultValue]) { - defaultValue = keyAlgMap[defaultValue]; - } - - 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) { - const mappedSigAlgs = templateData.signatureAlgorithm.allowedAlgorithms.map( - (alg: string) => { - const sigAlgMap: 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 sigAlgMap[alg] || alg; - } - ); - setAllowedSignatureAlgorithms(mappedSigAlgs); - } - - if (templateData.keyAlgorithm?.allowedKeyTypes) { - const mappedKeyAlgs = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => { - const keyAlgMap: Record = { - "RSA-2048": CertKeyAlgorithm.RSA_2048, - "RSA-3072": CertKeyAlgorithm.RSA_3072, - "RSA-4096": CertKeyAlgorithm.RSA_4096, - "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256, - "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384 - }; - return keyAlgMap[alg] || alg; - }); - setAllowedKeyAlgorithms(mappedKeyAlgs); - } - - const allAllowedKeyUsages: string[] = []; - if (templateData.keyUsages?.requiredUsages?.all) { - allAllowedKeyUsages.push(...templateData.keyUsages.requiredUsages.all); - } - if (templateData.keyUsages?.optionalUsages?.all) { - allAllowedKeyUsages.push(...templateData.keyUsages.optionalUsages.all); - } - setAllowedKeyUsages([...new Set(allAllowedKeyUsages)]); - - const allAllowedExtendedKeyUsages: string[] = []; - if (templateData.extendedKeyUsages?.requiredUsages?.all) { - allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.requiredUsages.all); - } - if (templateData.extendedKeyUsages?.optionalUsages?.all) { - allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.optionalUsages.all); - } - setAllowedExtendedKeyUsages([...new Set(allAllowedExtendedKeyUsages)]); - - if (templateData.attributes && Array.isArray(templateData.attributes)) { - const subjectAttrs: Array<{ - type: "common_name"; - value: string; - }> = []; - - templateData.attributes.forEach((attr) => { - if ( - (attr.include === "mandatory" || - attr.include === "optional" || - attr.include === "prohibit") && - attr.value && - attr.value.length > 0 - ) { - attr.value.forEach((val: string) => { - subjectAttrs.push({ type: attr.type as any, value: val }); - }); - } - }); - - if (subjectAttrs.length > 0) { - setValue("subjectAttributes", subjectAttrs); - } else { - setValue("subjectAttributes", [{ type: "common_name", value: "" }]); - } + if (templateData && selectedProfile && popUp?.certificateIssuance?.isOpen) { + if (templateData.algorithms?.signature && templateData.algorithms.signature.length > 0) { + setAllowedSignatureAlgorithms(templateData.algorithms.signature); + } else { + setAllowedSignatureAlgorithms([]); } if ( - templateData.subjectAlternativeNames && - Array.isArray(templateData.subjectAlternativeNames) + templateData.algorithms?.keyAlgorithm && + templateData.algorithms.keyAlgorithm.length > 0 ) { - const templateSans: Array<{ type: "dns" | "ip" | "email" | "uri"; value: string }> = []; - - templateData.subjectAlternativeNames.forEach((sanPolicy) => { - if ( - (sanPolicy.include === "mandatory" || - sanPolicy.include === "optional" || - sanPolicy.include === "prohibit") && - sanPolicy.value && - sanPolicy.value.length > 0 - ) { - const typeMapping: Record = { - dns_name: "dns", - ip_address: "ip", - email: "email", - uri: "uri" - }; - - const mappedType = typeMapping[sanPolicy.type]; - if (mappedType) { - sanPolicy.value.forEach((val: string) => { - templateSans.push({ type: mappedType, value: val }); - }); - } - } - }); - - if (templateSans.length > 0) { - setValue("subjectAltNames", templateSans); - } + setAllowedKeyAlgorithms(templateData.algorithms.keyAlgorithm); + } else { + setAllowedKeyAlgorithms([]); } - const resetKeyUsages = { - [CertKeyUsage.DIGITAL_SIGNATURE]: false, - [CertKeyUsage.KEY_ENCIPHERMENT]: false, - [CertKeyUsage.NON_REPUDIATION]: false, - [CertKeyUsage.DATA_ENCIPHERMENT]: false, - [CertKeyUsage.KEY_AGREEMENT]: false, - [CertKeyUsage.KEY_CERT_SIGN]: false, - [CertKeyUsage.CRL_SIGN]: false, - [CertKeyUsage.ENCIPHER_ONLY]: false, - [CertKeyUsage.DECIPHER_ONLY]: false - }; - - const resetExtendedKeyUsages = { - [CertExtendedKeyUsage.CLIENT_AUTH]: false, - [CertExtendedKeyUsage.CODE_SIGNING]: false, - [CertExtendedKeyUsage.EMAIL_PROTECTION]: false, - [CertExtendedKeyUsage.OCSP_SIGNING]: false, - [CertExtendedKeyUsage.SERVER_AUTH]: false, - [CertExtendedKeyUsage.TIMESTAMPING]: false - }; - - const templateToEnumMap = { - digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, - digitalSignature: CertKeyUsage.DIGITAL_SIGNATURE, - key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, - keyEncipherment: CertKeyUsage.KEY_ENCIPHERMENT, - non_repudiation: CertKeyUsage.NON_REPUDIATION, - nonRepudiation: CertKeyUsage.NON_REPUDIATION, - data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, - dataEncipherment: CertKeyUsage.DATA_ENCIPHERMENT, - key_agreement: CertKeyUsage.KEY_AGREEMENT, - keyAgreement: CertKeyUsage.KEY_AGREEMENT, - key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, - keyCertSign: CertKeyUsage.KEY_CERT_SIGN, - crl_sign: CertKeyUsage.CRL_SIGN, - cRLSign: CertKeyUsage.CRL_SIGN, - encipher_only: CertKeyUsage.ENCIPHER_ONLY, - encipherOnly: CertKeyUsage.ENCIPHER_ONLY, - decipher_only: CertKeyUsage.DECIPHER_ONLY, - decipherOnly: CertKeyUsage.DECIPHER_ONLY, - client_auth: CertExtendedKeyUsage.CLIENT_AUTH, - clientAuth: CertExtendedKeyUsage.CLIENT_AUTH, - server_auth: CertExtendedKeyUsage.SERVER_AUTH, - serverAuth: CertExtendedKeyUsage.SERVER_AUTH, - code_signing: CertExtendedKeyUsage.CODE_SIGNING, - codeSigning: CertExtendedKeyUsage.CODE_SIGNING, - email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, - emailProtection: CertExtendedKeyUsage.EMAIL_PROTECTION, - ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, - ocspSigning: CertExtendedKeyUsage.OCSP_SIGNING, - time_stamping: CertExtendedKeyUsage.TIMESTAMPING, - timestamping: CertExtendedKeyUsage.TIMESTAMPING, - timeStamping: CertExtendedKeyUsage.TIMESTAMPING - }; - - const currentKeyUsages = { ...resetKeyUsages }; - if (templateData.keyUsages?.requiredUsages?.all) { - templateData.keyUsages.requiredUsages.all.forEach((usage: string) => { - const enumValue = (templateToEnumMap as any)[usage]; - if (enumValue && enumValue in currentKeyUsages) { - (currentKeyUsages as any)[enumValue] = true; - } - }); + if (templateData.validity?.max) { + setValue("ttl", templateData.validity.max); } - const currentExtendedKeyUsages = { ...resetExtendedKeyUsages }; - if (templateData.extendedKeyUsages?.requiredUsages?.all) { - templateData.extendedKeyUsages.requiredUsages.all.forEach((usage: string) => { - const enumValue = (templateToEnumMap as any)[usage]; - if (enumValue && enumValue in currentExtendedKeyUsages) { - (currentExtendedKeyUsages as any)[enumValue] = true; - } - }); + const keyUsages: string[] = []; + if (templateData.keyUsages?.required) { + keyUsages.push(...templateData.keyUsages.required); } + if (templateData.keyUsages?.allowed) { + keyUsages.push(...templateData.keyUsages.allowed); + } + setAllowedKeyUsages(keyUsages); - setValue("keyUsages", currentKeyUsages); - setValue("extendedKeyUsages", currentExtendedKeyUsages); + const extendedKeyUsages: string[] = []; + if (templateData.extendedKeyUsages?.required) { + extendedKeyUsages.push(...templateData.extendedKeyUsages.required); + } + if (templateData.extendedKeyUsages?.allowed) { + extendedKeyUsages.push(...templateData.extendedKeyUsages.allowed); + } + setAllowedExtendedKeyUsages(extendedKeyUsages); + + setRequiredKeyUsages(templateData.keyUsages?.required || []); + setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []); + + const initialKeyUsages: Record = {}; + const initialExtendedKeyUsages: Record = {}; + + (templateData.keyUsages?.required || []).forEach((usage: string) => { + initialKeyUsages[usage] = true; + }); + + (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { + initialExtendedKeyUsages[usage] = true; + }); + + setValue("keyUsages", initialKeyUsages); + setValue("extendedKeyUsages", initialExtendedKeyUsages); } - }, [templateData, selectedProfile, setValue]); + }, [templateData, selectedProfile, setValue, popUp?.certificateIssuance?.isOpen]); useEffect(() => { if (cert) { @@ -451,83 +297,138 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } }, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]); - const onFormSubmit = async ({ - profileId, - subjectAttributes, - subjectAltNames, - ttl, - signatureAlgorithm, - keyAlgorithm, - keyUsages, - extendedKeyUsages - }: FormData) => { - try { - if (!currentProject?.slug) return; + const getAttributeValue = useCallback( + (subjectAttributes: typeof schema._type.subjectAttributes, type: string) => { + const foundAttr = subjectAttributes.find((attr) => attr.type === type); + return foundAttr?.value || ""; + }, + [] + ); - const getAttributeValue = (type: string) => { - const foundAttr = subjectAttributes.find((attr) => attr.type === type); - return foundAttr?.value || ""; - }; + const formatSubjectAltNames = useCallback( + (subjectAltNames: typeof schema._type.subjectAltNames) => { + return subjectAltNames + .filter((san) => san.value.trim()) + .map((san) => san.value.trim()) + .join(", "); + }, + [] + ); - const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ - profileId, - projectSlug: currentProject.slug, - commonName: getAttributeValue("common_name"), - subjectAltNames: subjectAltNames - .filter((san) => san.value.trim()) - .map((san) => san.value.trim()) - .join(", "), - ttl, - signatureAlgorithm: (() => { - const frontendToBackendSigAlg: Record = { - "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 - : undefined; - })(), - keyAlgorithm: (() => { - const frontendToBackendKeyAlg: Record = { - 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; - })(), - keyUsages: Object.entries(keyUsages) - .filter(([, value]) => value) - .map(([key]) => key as CertKeyUsage), - extendedKeyUsages: Object.entries(extendedKeyUsages) - .filter(([, value]) => value) - .map(([key]) => key as CertExtendedKeyUsage) - }); + const filterUsages = useCallback(>(usages: T) => { + return Object.entries(usages) + .filter(([, value]) => value) + .map(([key]) => key); + }, []); - reset(); + const onFormSubmit = useCallback( + async ({ + profileId: formProfileId, + subjectAttributes, + subjectAltNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages + }: FormData) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Project not found. Please refresh and try again.", + type: "error" + }); + return; + } - setCertificateDetails({ - serialNumber, - certificate, - certificateChain, - privateKey - }); + if (!formProfileId) { + createNotification({ + text: "Please select a certificate profile.", + type: "error" + }); + return; + } - createNotification({ - text: "Successfully created certificate", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to create certificate", - type: "error" - }); + const commonName = getAttributeValue(subjectAttributes, "common_name"); + if (!commonName.trim()) { + createNotification({ + text: "Common name is required.", + type: "error" + }); + return; + } + + const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate( + { + profileId: formProfileId, + projectSlug: currentProject.slug, + commonName, + subjectAltNames: formatSubjectAltNames(subjectAltNames), + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages: filterUsages(keyUsages) as CertKeyUsage[], + extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] + } + ); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error("Certificate creation failed:", err); + const errorMessage = + err instanceof Error + ? err.message + : "An unexpected error occurred while creating the certificate"; + createNotification({ + text: `Failed to create certificate: ${errorMessage}`, + type: "error" + }); + } + }, + [ + currentProject?.slug, + createCertificate, + reset, + getAttributeValue, + formatSubjectAltNames, + filterUsages + ] + ); + + const getModalTitle = () => { + if (certificateDetails) return "Certificate Created Successfully"; + if (cert) return "Certificate Details"; + return "Issue New Certificate"; + }; + + const getModalSubTitle = () => { + if (certificateDetails) return "Certificate has been successfully created and is ready for use"; + if (cert) return "View certificate information"; + return "Issue a new certificate using a certificate profile"; + }; + + const getSanPlaceholder = (sanType: string) => { + switch (sanType) { + case "dns": + return "example.com or *.example.com"; + case "ip": + return "192.168.1.1"; + case "email": + return "admin@example.com"; + case "uri": + return "https://example.com"; + default: + return "Enter value"; } }; @@ -536,26 +437,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } isOpen={popUp?.certificateIssuance?.isOpen} onOpenChange={(isOpen) => { handlePopUpToggle("certificateIssuance", isOpen); - setCertificateDetails(null); - reset(); + if (!isOpen) { + resetAllState(); + } }} > - + {certificateDetails && (

-

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}

)} @@ -592,9 +479,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } className="text-center" content={ - 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. + 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. } > @@ -629,7 +516,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } {(selectedProfile || profileId) && ( <> -
{value.map((attr, index) => ( -
+ // eslint-disable-next-line react/no-array-index-key +
{ @@ -735,15 +626,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } 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" - } + placeholder={getSanPlaceholder(san.type)} className="flex-1" /> onChange(e)} className="w-full" - placeholder="Use template default" + placeholder={ + availableSignatureAlgorithms.length > 0 + ? "Select signature algorithm" + : "No algorithms available" + } position="popper" > - {SIGNATURE_ALGORITHMS_OPTIONS.filter((algorithm) => { - if (allowedSignatureAlgorithms.length === 0) return true; - return allowedSignatureAlgorithms.includes(algorithm.value); - }).map((algorithm) => ( + {availableSignatureAlgorithms.map((algorithm) => ( {algorithm.label} @@ -839,19 +723,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } {...field} onValueChange={(e) => onChange(e)} className="w-full" - placeholder="Use template default" + placeholder={ + availableKeyAlgorithms.length > 0 + ? "Select key algorithm" + : "No algorithms available" + } position="popper" > - {certKeyAlgorithms - .filter((algorithm) => { - if (allowedKeyAlgorithms.length === 0) return true; - return allowedKeyAlgorithms.includes(algorithm.value); - }) - .map((algorithm) => ( - - {algorithm.label} - - ))} + {availableKeyAlgorithms.map((algorithm) => ( + + {algorithm.label} + + ))} )} @@ -863,88 +746,80 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } 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)} - /> - -
- )} - /> - ))} -
+
+ {filteredKeyUsages.map(({ label, value }) => { + const isRequired = requiredKeyUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
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)} - /> - -
- )} - /> - ))} -
+
+ {filteredExtendedKeyUsages.map(({ label, value }) => { + const isRequired = requiredExtendedKeyUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
@@ -964,7 +839,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index 57977eb1b..9f6e6c60d 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -87,16 +87,16 @@ export const PkiSubscriberSection = () => { const subscriberName = subscriberStatusData?.subscriberName || ""; return ( -
+
-

Subscribers

+

Subscribers

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

Templates

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

Templates

+
+ + {(isAllowed) => ( + + )} + +
-
- ) - } + )} 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 3255a82d9..2bd4a290e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -165,7 +165,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } if (isEdit) { const updateData: any = { profileId: profile.id, - name: data.slug, + slug: data.slug, description: data.description }; @@ -240,7 +240,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -319,7 +319,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > {certificateTemplates.map((template) => ( - {template.slug} + {template.name} ))} @@ -376,17 +376,17 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="estConfig.disableBootstrapCaValidation" render={({ field: { value, onChange }, fieldState: { error } }) => ( -
+
- + Disable Bootstrap CA Validation -

+

Skip CA certificate validation during EST bootstrap phase

@@ -433,7 +433,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } rows={6} className="w-full font-mono text-xs" /> -

+

Paste the complete CA certificate chain in PEM format

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 477590855..c730bc0cc 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,7 +1,17 @@ /* eslint-disable no-nested-ternary */ -import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useCallback } from "react"; +import { + faCheck, + faCircleInfo, + faCopy, + faEdit, + faEllipsis, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createNotification } from "@app/components/notifications"; import { Badge, DropdownMenu, @@ -18,12 +28,10 @@ import { ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; +import { usePopUp, useToggle } from "@app/hooks"; 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 { @@ -37,9 +45,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = const { data: caData } = useGetCaById(profile.caId); - const { popUp, handlePopUpToggle } = usePopUp([ - "certificateIssuance" - ] as const); + const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const); const [isIdCopied, setIsIdCopied] = useToggle(false); @@ -100,15 +106,15 @@ 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 5f17b9a92..ad6f35d87 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx @@ -54,7 +54,7 @@ export const CertificateTemplatesV2Tab = () => { setIsDeleteModalOpen(false); setSelectedTemplate(null); createNotification({ - text: `Certificate template "${selectedTemplate.slug}" deleted successfully`, + text: `Certificate template "${selectedTemplate.name}" deleted successfully`, type: "success" }); } catch (error: any) { @@ -103,12 +103,12 @@ export const CertificateTemplatesV2Tab = () => { { setIsDeleteModalOpen(isOpen); if (!isOpen) setSelectedTemplate(null); }} - deleteKey={selectedTemplate.slug} + deleteKey={selectedTemplate.name} onDeleteApproved={handleDeleteConfirm} /> 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 7bc1cf15e..53e344c52 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx @@ -1,6 +1,7 @@ -import { useMemo } from "react"; +/* eslint-disable no-nested-ternary */ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faExclamationTriangle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -19,8 +20,7 @@ import { ModalContent, Select, SelectItem, - TextArea, - Tooltip + TextArea } from "@app/components/v2"; import { useProject } from "@app/context"; import { @@ -29,7 +29,15 @@ import { } from "@app/hooks/api/certificateTemplates/mutations"; import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; -import { INCLUDE_TYPE_OPTIONS, SAN_TYPE_OPTIONS, SUBJECT_ATTRIBUTE_TYPE_OPTIONS } from "./shared/certificate-constants"; +import { + CertDurationUnit, + CertExtendedKeyUsageType, + CertKeyUsageType, + SAN_INCLUDE_OPTIONS, + SAN_TYPE_OPTIONS, + SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS, + SUBJECT_ATTRIBUTE_TYPE_OPTIONS +} from "./shared/certificate-constants"; import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared"; export type FormData = TemplateFormData; @@ -42,7 +50,9 @@ interface Props { } const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = { - common_name: "Common Name (CN)" + common_name: "Common Name (CN)", + organization: "Organization (O)", + country: "Country (C)" }; const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = { @@ -52,10 +62,16 @@ const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = { uri: "URI" }; -const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_TYPE_OPTIONS)[number], string> = { - mandatory: "Mandatory", - optional: "Optional", - prohibit: "Prohibited" +const SUBJECT_ATTRIBUTE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number], string> = + { + optional: "Allow", + prohibit: "Deny" + }; + +const SAN_INCLUDE_LABELS: Record<(typeof SAN_INCLUDE_OPTIONS)[number], string> = { + mandatory: "Require", + optional: "Allow", + prohibit: "Deny" }; const SIGNATURE_ALGORITHMS = [ @@ -83,110 +99,260 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const isEdit = mode === "edit" && template; - const validateAttributeRules = (attributes: FormData["attributes"]) => { - if (!attributes) return { isValid: true, warnings: [], invalidIndices: [] }; + const convertApiToUiFormat = (templateData: TCertificateTemplateV2New): FormData => { + const attributes: any[] = []; + if (templateData.subject && Array.isArray(templateData.subject)) { + templateData.subject.forEach((subj) => { + if (subj.allowed && Array.isArray(subj.allowed)) { + subj.allowed.forEach((allowedValue) => { + attributes.push({ + type: subj.type, + include: "optional", + value: [allowedValue] + }); + }); + } + if (subj.denied && Array.isArray(subj.denied)) { + subj.denied.forEach((deniedValue) => { + attributes.push({ + type: subj.type, + include: "prohibit", + value: [deniedValue] + }); + }); + } + }); + } - const warnings: string[] = []; - const invalidIndices: number[] = []; + const subjectAlternativeNames: any[] = []; + if (templateData.sans && Array.isArray(templateData.sans)) { + templateData.sans.forEach((san) => { + if (san.required && Array.isArray(san.required)) { + san.required.forEach((requiredValue) => { + subjectAlternativeNames.push({ + type: san.type, + include: "mandatory", + value: [requiredValue] + }); + }); + } + if (san.allowed && Array.isArray(san.allowed)) { + san.allowed.forEach((allowedValue) => { + subjectAlternativeNames.push({ + type: san.type, + include: "optional", + value: [allowedValue] + }); + }); + } + if (san.denied && Array.isArray(san.denied)) { + san.denied.forEach((deniedValue) => { + subjectAlternativeNames.push({ + type: san.type, + include: "prohibit", + value: [deniedValue] + }); + }); + } + }); + } - const attributesByType = attributes.reduce((acc, attr, index) => { - if (!acc[attr.type]) acc[attr.type] = []; - acc[attr.type].push({ ...attr, index }); - return acc; - }, {} as Record>); + const keyUsages = { + requiredUsages: (templateData.keyUsages?.required || []) as CertKeyUsageType[], + optionalUsages: (templateData.keyUsages?.allowed || []) as CertKeyUsageType[] + }; - Object.entries(attributesByType).forEach(([type, attrs]) => { - const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory'); + const extendedKeyUsages = { + requiredUsages: (templateData.extendedKeyUsages?.required || + []) as CertExtendedKeyUsageType[], + optionalUsages: (templateData.extendedKeyUsages?.allowed || []) as CertExtendedKeyUsageType[] + }; - 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.`); - } + const validity = templateData.validity?.max + ? (() => { + const maxValue = templateData.validity.max; + const match = maxValue.match(/^(\d+)([dmy])$/); + if (match) { + const value = parseInt(match[1], 10); + const unitChar = match[2]; + let unit: CertDurationUnit = CertDurationUnit.DAYS; + if (unitChar === "d") { + unit = CertDurationUnit.DAYS; + } else if (unitChar === "m") { + unit = CertDurationUnit.MONTHS; + } else { + unit = CertDurationUnit.YEARS; + } + return { + maxDuration: { value, unit } + }; + } + return { maxDuration: { value: 365, unit: CertDurationUnit.DAYS } }; + })() + : { maxDuration: { value: 365, unit: CertDurationUnit.DAYS } }; - 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.`); - } - }); + const signatureAlgorithm = { + allowedAlgorithms: templateData.algorithms?.signature || [], + defaultAlgorithm: "" + }; - return { isValid: warnings.length === 0, warnings, invalidIndices }; + const keyAlgorithm = { + allowedKeyTypes: templateData.algorithms?.keyAlgorithm || [], + defaultKeyType: "" + }; + + return { + slug: templateData.name || "", + description: templateData.description || "", + attributes, + subjectAlternativeNames, + keyUsages, + extendedKeyUsages, + validity, + signatureAlgorithm, + keyAlgorithm + }; }; + const getDefaultValues = (): FormData => ({ + slug: "", + description: "", + attributes: [], + keyUsages: { requiredUsages: [], optionalUsages: [] }, + extendedKeyUsages: { requiredUsages: [], optionalUsages: [] }, + subjectAlternativeNames: [], + validity: { + maxDuration: { value: 365, unit: CertDurationUnit.DAYS } + }, + signatureAlgorithm: { + allowedAlgorithms: [], + defaultAlgorithm: "" + }, + keyAlgorithm: { + allowedKeyTypes: [], + defaultKeyType: "" + } + }); + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(templateSchema), - defaultValues: isEdit - ? { - slug: template.slug, - description: template.description || "", - attributes: template.attributes || [], - keyUsages: { - requiredUsages: template.keyUsages?.requiredUsages?.all || [], - optionalUsages: template.keyUsages?.optionalUsages?.all || [] - }, - extendedKeyUsages: { - requiredUsages: template.extendedKeyUsages?.requiredUsages?.all || [], - optionalUsages: template.extendedKeyUsages?.optionalUsages?.all || [] - }, - subjectAlternativeNames: template.subjectAlternativeNames || [], - validity: template.validity || { maxDuration: { value: 365, unit: "days" } }, - signatureAlgorithm: template.signatureAlgorithm || { - allowedAlgorithms: ["SHA256-RSA"], - defaultAlgorithm: "SHA256-RSA" - }, - keyAlgorithm: template.keyAlgorithm || { - allowedKeyTypes: ["RSA-2048"], - defaultKeyType: "RSA-2048" - } - } - : { - slug: "", - description: "", - attributes: [], - keyUsages: { requiredUsages: [], optionalUsages: [] }, - extendedKeyUsages: { requiredUsages: [], optionalUsages: [] }, - subjectAlternativeNames: [], - validity: { - maxDuration: { value: 365, unit: "days" } - }, - signatureAlgorithm: { - allowedAlgorithms: ["SHA256-RSA"], - defaultAlgorithm: "SHA256-RSA" - }, - keyAlgorithm: { - allowedKeyTypes: ["RSA-2048"], - defaultKeyType: "RSA-2048" - } - } + defaultValues: getDefaultValues() }); + useEffect(() => { + if (isEdit && template) { + const convertedData = convertApiToUiFormat(template); + reset(convertedData); + } else if (!isEdit) { + reset(getDefaultValues()); + } + }, [isEdit, template, reset]); + const watchedAttributes = watch("attributes") || []; const watchedSans = watch("subjectAlternativeNames") || []; const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] }; - const watchedExtendedKeyUsages = watch("extendedKeyUsages") || { requiredUsages: [], optionalUsages: [] }; + const watchedExtendedKeyUsages = watch("extendedKeyUsages") || { + requiredUsages: [], + optionalUsages: [] + }; - const attributeValidation = useMemo(() => - validateAttributeRules(watchedAttributes), - [watchedAttributes] - ); + const transformToNewApiFormat = (data: FormData) => { + const subject = + data.attributes?.map((attr) => { + const result: any = { type: attr.type }; + + if (attr.include === "optional" && attr.value && attr.value.length > 0) { + result.allowed = attr.value; + } else if (attr.include === "prohibit" && attr.value && attr.value.length > 0) { + result.denied = attr.value; + } + + return result; + }) || []; + + const sans = + data.subjectAlternativeNames?.map((san) => { + const result: any = { type: san.type }; + + if (san.include === "mandatory" && san.value && san.value.length > 0) { + result.required = san.value; + } else if (san.include === "optional" && san.value && san.value.length > 0) { + result.allowed = san.value; + } else if (san.include === "prohibit" && san.value && san.value.length > 0) { + result.denied = san.value; + } + + return result; + }) || []; + + const keyUsages: any = {}; + if (data.keyUsages?.requiredUsages && data.keyUsages.requiredUsages.length > 0) { + keyUsages.required = data.keyUsages.requiredUsages; + } + if (data.keyUsages?.optionalUsages && data.keyUsages.optionalUsages.length > 0) { + keyUsages.allowed = data.keyUsages.optionalUsages; + } + + const extendedKeyUsages: any = {}; + if ( + data.extendedKeyUsages?.requiredUsages && + data.extendedKeyUsages.requiredUsages.length > 0 + ) { + extendedKeyUsages.required = data.extendedKeyUsages.requiredUsages; + } + if ( + data.extendedKeyUsages?.optionalUsages && + data.extendedKeyUsages.optionalUsages.length > 0 + ) { + extendedKeyUsages.allowed = data.extendedKeyUsages.optionalUsages; + } + + const algorithms: any = {}; + if ( + data.signatureAlgorithm?.allowedAlgorithms && + data.signatureAlgorithm.allowedAlgorithms.length > 0 + ) { + algorithms.signature = data.signatureAlgorithm.allowedAlgorithms; + } + if (data.keyAlgorithm?.allowedKeyTypes && data.keyAlgorithm.allowedKeyTypes.length > 0) { + algorithms.keyAlgorithm = data.keyAlgorithm.allowedKeyTypes; + } + + const validity: any = {}; + if (data.validity?.maxDuration) { + let unit = "d"; + if (data.validity.maxDuration.unit === CertDurationUnit.DAYS) { + unit = "d"; + } else if (data.validity.maxDuration.unit === CertDurationUnit.MONTHS) { + unit = "m"; + } else { + unit = "y"; + } + validity.max = `${data.validity.maxDuration.value}${unit}`; + } + + return { + name: data.slug, + description: data.description, + subject: subject.length > 0 ? subject : undefined, + sans: sans.length > 0 ? sans : undefined, + keyUsages: Object.keys(keyUsages).length > 0 ? keyUsages : undefined, + extendedKeyUsages: Object.keys(extendedKeyUsages).length > 0 ? extendedKeyUsages : undefined, + algorithms: Object.keys(algorithms).length > 0 ? algorithms : undefined, + validity: Object.keys(validity).length > 0 ? validity : undefined + }; + }; 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 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()) + const hasEmptySanValues = data.subjectAlternativeNames?.some( + (san) => !san.value || san.value.length === 0 || san.value.some((v) => !v.trim()) ); if (hasEmptyAttributeValues || hasEmptySanValues) { @@ -197,60 +363,18 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" return; } + const transformedData = transformToNewApiFormat(data); + if (isEdit) { const updateData = { templateId: template.id, - name: data.slug, - description: data.description, - attributes: data.attributes || [], - keyUsages: { - requiredUsages: { all: data.keyUsages?.requiredUsages || [] }, - optionalUsages: { all: data.keyUsages?.optionalUsages || [] } - }, - extendedKeyUsages: { - requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] }, - optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] } - }, - subjectAlternativeNames: data.subjectAlternativeNames || [], - validity: { - maxDuration: data.validity?.maxDuration || { value: 365, unit: "days" as const } - }, - signatureAlgorithm: { - allowedAlgorithms: data.signatureAlgorithm?.allowedAlgorithms || ["SHA256-RSA"], - defaultAlgorithm: data.signatureAlgorithm?.defaultAlgorithm || "SHA256-RSA" - }, - keyAlgorithm: { - allowedKeyTypes: data.keyAlgorithm?.allowedKeyTypes || ["RSA-2048"], - defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048" - } + ...transformedData }; await updateTemplate.mutateAsync(updateData); } else { const createData = { projectId: currentProject!.id, - slug: data.slug, - description: data.description, - attributes: data.attributes || [], - keyUsages: { - requiredUsages: { all: data.keyUsages?.requiredUsages || [] }, - optionalUsages: { all: data.keyUsages?.optionalUsages || [] } - }, - extendedKeyUsages: { - requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] }, - optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] } - }, - subjectAlternativeNames: data.subjectAlternativeNames || [], - validity: { - maxDuration: data.validity?.maxDuration || { value: 365, unit: "days" as const } - }, - signatureAlgorithm: { - allowedAlgorithms: data.signatureAlgorithm?.allowedAlgorithms || ["SHA256-RSA"], - defaultAlgorithm: data.signatureAlgorithm?.defaultAlgorithm || "SHA256-RSA" - }, - keyAlgorithm: { - allowedKeyTypes: data.keyAlgorithm?.allowedKeyTypes || ["RSA-2048"], - defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048" - } + ...transformedData }; await createTemplate.mutateAsync(createData); } @@ -274,7 +398,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const addAttribute = () => { const newAttribute = { type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0], - include: INCLUDE_TYPE_OPTIONS[1], + include: SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS[0], value: ["*"] }; setValue("attributes", [...watchedAttributes, newAttribute]); @@ -288,7 +412,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" const addSan = () => { const newSan = { type: SAN_TYPE_OPTIONS[0], - include: INCLUDE_TYPE_OPTIONS[1], + include: SAN_INCLUDE_OPTIONS[1], value: ["*"] }; setValue("subjectAlternativeNames", [...watchedSans, newSan]); @@ -299,14 +423,20 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" setValue("subjectAlternativeNames", newSans); }; - const handleKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => { + const handleKeyUsagesChange = (usages: { + requiredUsages: string[]; + optionalUsages: string[]; + }) => { setValue("keyUsages", { requiredUsages: usages.requiredUsages as any, optionalUsages: usages.optionalUsages as any }); }; - const handleExtendedKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => { + const handleExtendedKeyUsagesChange = (usages: { + requiredUsages: string[]; + optionalUsages: string[]; + }) => { setValue("extendedKeyUsages", { requiredUsages: usages.requiredUsages as any, optionalUsages: usages.optionalUsages as any @@ -328,7 +458,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"} subTitle={ isEdit - ? `Update configuration for ${template?.slug}` + ? `Update configuration for ${template?.name}` : "Define comprehensive certificate policies, validation rules, and constraints" } > @@ -380,157 +510,82 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" - {/* Validation warnings */} - {!attributeValidation.isValid && attributeValidation.warnings.length > 0 && ( -
-
- -
-

Validation Warnings

-
    - {attributeValidation.warnings.map((warning, index) => ( -
  • • {warning}
  • - ))} -
-
-
-
- )} -
{watchedAttributes.length === 0 ? ( -
+
No subject attributes configured yet. Click "Add Attribute" to get started.
) : ( watchedAttributes.map((attr, index) => { - const isInvalid = attributeValidation.invalidIndices.includes(index); - const errorClass = isInvalid ? "border-red-500 focus:border-red-500" : ""; - return ( -
- {isInvalid ? ( - -
- - - - - { - const newAttributes = [...watchedAttributes]; - newAttributes[index] = { - ...attr, - value: e.target.value.trim() ? [e.target.value.trim()] : [] - }; - setValue("attributes", newAttributes); - }} - className={`flex-1 ${errorClass} ${ - attr.value && attr.value.length > 0 && attr.value[0] === "" - ? "border-red-500 focus:border-red-500" - : "" - }`} - required - /> -
-
- ) : ( - <> - - - - - { - const newAttributes = [...watchedAttributes]; - newAttributes[index] = { - ...attr, - value: e.target.value.trim() ? [e.target.value.trim()] : [] - }; - setValue("attributes", newAttributes); - }} - className={`flex-1 ${ - attr.value && attr.value.length > 0 && attr.value[0] === "" - ? "border-red-500 focus:border-red-500" - : "" - }`} - required - /> - - )} - - {watchedAttributes.length > 1 && ( - removeAttribute(index)} +
+ + + + + { + const newAttributes = [...watchedAttributes]; + newAttributes[index] = { + ...attr, + value: e.target.value.trim() ? [e.target.value.trim()] : [] + }; + setValue("attributes", newAttributes); + }} + className={`flex-1 ${ + attr.value && attr.value.length > 0 && attr.value[0] === "" + ? "border-red-500 focus:border-red-500" + : "" + }`} + required + /> + + {watchedAttributes.length > 1 && ( + removeAttribute(index)} + > + + + )} +
); }) )} @@ -557,13 +612,17 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
{watchedSans.length === 0 ? ( -
+
No subject alternative names configured yet. Click "Add SAN" to get started.
) : ( watchedSans.map((san, index) => ( -
+
@@ -643,221 +702,147 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" - - Constraints + + Algorithms -
-
-
- ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - - - )} - /> -
+
+
+

+ Signature Algorithms +

+ ( + +
+ {SIGNATURE_ALGORITHMS.map((alg) => { + const isSelected = field.value?.includes(alg); + return ( +
+ { + const current = field.value || []; + let newValue; + if (checked && !isSelected) { + newValue = [...current, alg]; + } else if (!checked && isSelected) { + newValue = current.filter((a) => a !== alg); + } else { + return; + } + field.onChange(newValue); + }} + /> + +
+ ); + })} +
+
+ )} + />
-
-
- ( - -
- {SIGNATURE_ALGORITHMS.map((alg) => { - const isSelected = field.value?.includes(alg); - return ( -
- { - const current = field.value || []; - let newValue; - if (checked && !isSelected) { - newValue = [...current, alg]; - } else if (!checked && isSelected) { - if (current.length > 1) { - newValue = current.filter((a) => a !== alg); - } else { - return; - } - } else { - return; - } - field.onChange(newValue); - - const currentDefault = watch( - "signatureAlgorithm.defaultAlgorithm" - ); - if (currentDefault && !newValue.includes(currentDefault)) { - setValue( - "signatureAlgorithm.defaultAlgorithm", - newValue[0] - ); - } - }} - /> - -
- ); - })} -
-
- )} - /> - - ( - - - - )} - /> + +
+ ); + })} +
+ + )} + /> +
+
+ + - ( - + Certificate Validity + +
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + - {(watch("keyAlgorithm.allowedKeyTypes") || []).map((alg: string) => ( - - {alg} - - ))} - - - )} - /> -
+ Days + Months + Years + + + )} + />
@@ -870,9 +855,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" colorSchema="primary" isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending} isDisabled={ - !formState.isValid || - !attributeValidation.isValid || - (isEdit ? updateTemplate.isPending : createTemplate.isPending) + !formState.isValid || (isEdit ? updateTemplate.isPending : createTemplate.isPending) } > {isEdit ? "Save Changes" : "Create"} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx index f6fa367f9..5065c044c 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx @@ -75,61 +75,64 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => { )} - {!isLoading && templates && templates.length > 0 && templates.map((template) => ( -
- - - + - - ))} + + + + + + ))}
{getEnrollmentTypeBadge(profile.enrollmentType)} + {getEnrollmentTypeBadge(profile.enrollmentType)} {caData?.friendlyName || caData?.commonName || profile.caId} - {templateData?.slug || profile.certificateTemplateId} + {templateData?.name || profile.certificateTemplateId} @@ -176,19 +182,17 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = Edit Profile )} - { - canIssueCertificate && ( - { - e.stopPropagation(); - handlePopUpToggle("certificateIssuance"); - }} - icon={} - > - Issue Certificate - - ) - } + {canIssueCertificate && ( + { + e.stopPropagation(); + handlePopUpToggle("certificateIssuance"); + }} + icon={} + > + Issue Certificate + + )} {canDeleteProfile && ( { @@ -202,7 +206,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = )} - +
-
-
{template.slug}
- {template.description && ( - - - - )} -
-
- {formatDate(template.createdAt)} - - - -
- - + {!isLoading && + templates && + templates.length > 0 && + templates.map((template) => ( +
+
+
{template.name}
+ {template.description && ( + + -
- - - {canEditTemplate && ( - { - e.stopPropagation(); - onEditTemplate(template); - }} - icon={} - > - Edit Template - )} - {canDeleteTemplate && ( - { - e.stopPropagation(); - onDeleteTemplate(template); - }} - icon={} - > - Delete Template - - )} - - -
+ {formatDate(template.createdAt)} + + + +
+ + + +
+
+ + {canEditTemplate && ( + { + e.stopPropagation(); + onEditTemplate(template); + }} + icon={} + > + Edit Template + + )} + {canDeleteTemplate && ( + { + e.stopPropagation(); + onDeleteTemplate(template); + }} + icon={} + > + Delete Template + + )} + +
+
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts index edeb2342e..684ffd85f 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts @@ -26,10 +26,16 @@ export enum CertExtendedKeyUsageType { TIME_STAMPING = "time_stamping" } -export enum CertIncludeType { - MANDATORY = "mandatory", - OPTIONAL = "optional", - PROHIBIT = "prohibit" +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" } export enum CertDurationUnit { @@ -39,7 +45,9 @@ export enum CertDurationUnit { } export enum CertSubjectAttributeType { - COMMON_NAME = "common_name" + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" } export const formatSANType = (type: CertSubjectAlternativeNameType): string => { @@ -104,61 +112,117 @@ export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => { switch (type) { case CertSubjectAttributeType.COMMON_NAME: - return "Common Name"; + return "Common Name (CN)"; + case CertSubjectAttributeType.ORGANIZATION: + return "Organization"; + case CertSubjectAttributeType.COUNTRY: + return "Country"; default: return type; } }; -export const formatIncludeType = (include: CertIncludeType): string => { - switch (include) { - case CertIncludeType.MANDATORY: - return "Mandatory"; - case CertIncludeType.OPTIONAL: - return "Optional"; - case CertIncludeType.PROHIBIT: - return "Prohibit"; +export const formatAttributeRule = (rule: CertAttributeRule): string => { + switch (rule) { + case CertAttributeRule.ALLOW: + return "Allow"; + case CertAttributeRule.DENY: + return "Deny"; + case CertAttributeRule.REQUIRE: + return "Require"; default: - return include; + return rule; } }; -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; +export const formatSanEffect = (effect: CertSanEffect): string => { + switch (effect) { + case CertSanEffect.ALLOW: + return "Allow"; + case CertSanEffect.DENY: + return "Deny"; + case CertSanEffect.REQUIRE: + return "Require"; default: - throw new Error(`Unknown SAN type: ${type}`); + return effect; } }; -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 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); \ No newline at end of file +export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); + +export const SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS = ["optional", "prohibit"] as const; +export const SAN_INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const; + +export const USAGE_STATES = { + REQUIRED: "required", + OPTIONAL: "optional" +} as const; + +export type UsageState = typeof USAGE_STATES[keyof typeof USAGE_STATES] | undefined; + +export const TEMPLATE_SIGNATURE_ALGORITHMS = [ + "SHA256-RSA", + "SHA384-RSA", + "SHA512-RSA", + "SHA256-ECDSA", + "SHA384-ECDSA", + "SHA512-ECDSA" +] as const; + +export const TEMPLATE_KEY_ALGORITHMS = [ + "RSA-2048", + "RSA-3072", + "RSA-4096", + "ECDSA-P256", + "ECDSA-P384" +] as const; + +// API format algorithm constants +export const API_SIGNATURE_ALGORITHMS = [ + "RSA-SHA256", + "RSA-SHA384", + "RSA-SHA512", + "ECDSA-SHA256", + "ECDSA-SHA384", + "ECDSA-SHA512" +] as const; + +export const API_KEY_ALGORITHMS = [ + "RSA_2048", + "RSA_3072", + "RSA_4096", + "EC_prime256v1", + "EC_secp384r1" +] as const; + +// Mapping functions between template and API formats +export 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; +}; + +export const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "RSA-2048": "RSA_2048", + "RSA-3072": "RSA_3072", + "RSA-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1" + }; + return mapping[templateFormat] || templateFormat; +}; + diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx index dc8636dbd..fa4b5ade0 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx @@ -1,152 +1,159 @@ -import { Checkbox } from "@app/components/v2"; +import React from "react"; + +import { Select, SelectItem } from "@app/components/v2"; import { CertExtendedKeyUsageType, CertKeyUsageType, + EXTENDED_KEY_USAGE_OPTIONS, formatExtendedKeyUsage, formatKeyUsage, - EXTENDED_KEY_USAGE_OPTIONS, KEY_USAGE_OPTIONS } from "./certificate-constants"; -type UsageState = "mandatory" | "optional" | undefined; +type KeyUsagePolicy = "allow" | "require" | "deny" | "none"; -type ThreeStateCheckboxProps = { - value: UsageState; - onChange: (newValue: UsageState) => void; - label: string; - id: string; -}; - -const ThreeStateCheckbox = ({ value, onChange, label, id }: ThreeStateCheckboxProps) => { - const handleClick = () => { - if (value === undefined) { - onChange("optional"); - } else if (value === "optional") { - onChange("mandatory"); - } else { - onChange(undefined); - } +interface KeyUsagesSectionProps { + watchedKeyUsages: { + requiredUsages: CertKeyUsageType[]; + optionalUsages: CertKeyUsageType[]; }; - - const getCheckboxState = () => { - if (value) return true; - return false; + watchedExtendedKeyUsages: { + requiredUsages: CertExtendedKeyUsageType[]; + optionalUsages: CertExtendedKeyUsageType[]; }; + onKeyUsagesChange: (usages: { + requiredUsages: CertKeyUsageType[]; + optionalUsages: CertKeyUsageType[]; + }) => void; + onExtendedKeyUsagesChange: (usages: { + requiredUsages: CertExtendedKeyUsageType[]; + optionalUsages: CertExtendedKeyUsageType[]; + }) => void; +} - const getIndeterminateState = () => { - return value === "optional"; - }; - - const getStateLabel = () => { - if (value === "mandatory") return " (Mandatory)"; - if (value === "optional") return " (Optional)"; - return ""; - }; - - return ( -
- - -
- ); -}; - -type KeyUsagesSectionProps = { - watchedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] }; - watchedExtendedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] }; - onKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void; - onExtendedKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void; -}; - -export const KeyUsagesSection = ({ - watchedKeyUsages = { requiredUsages: [], optionalUsages: [] }, - watchedExtendedKeyUsages = { requiredUsages: [], optionalUsages: [] }, +export const KeyUsagesSection: React.FC = ({ + watchedKeyUsages, + watchedExtendedKeyUsages, onKeyUsagesChange, onExtendedKeyUsagesChange -}: KeyUsagesSectionProps) => { - const getUsageState = (usage: string, data: { requiredUsages?: string[]; optionalUsages?: string[] }): UsageState => { - if (data.requiredUsages?.includes(usage)) return "mandatory"; - if (data.optionalUsages?.includes(usage)) return "optional"; - return undefined; +}) => { + const getKeyUsagePolicy = (usage: CertKeyUsageType): KeyUsagePolicy => { + if (watchedKeyUsages.requiredUsages.includes(usage)) return "require"; + if (watchedKeyUsages.optionalUsages.includes(usage)) return "allow"; + return "deny"; }; - const handleKeyUsageChange = (usage: CertKeyUsageType, newState: UsageState) => { - const currentRequired = watchedKeyUsages.requiredUsages || []; - const currentOptional = watchedKeyUsages.optionalUsages || []; + const getExtendedKeyUsagePolicy = (usage: CertExtendedKeyUsageType): KeyUsagePolicy => { + if (watchedExtendedKeyUsages.requiredUsages.includes(usage)) return "require"; + if (watchedExtendedKeyUsages.optionalUsages.includes(usage)) return "allow"; + return "deny"; + }; - let newRequired = currentRequired.filter(u => u !== usage); - let newOptional = currentOptional.filter(u => u !== usage); + const handleKeyUsagePolicyChange = (usage: CertKeyUsageType, policy: KeyUsagePolicy) => { + const newRequired = watchedKeyUsages.requiredUsages.filter((u) => u !== usage); + const newOptional = watchedKeyUsages.optionalUsages.filter((u) => u !== usage); - if (newState === "mandatory") { - newRequired = [...newRequired, usage]; - } else if (newState === "optional") { - newOptional = [...newOptional, usage]; + if (policy === "require") { + newRequired.push(usage); + } else if (policy === "allow") { + newOptional.push(usage); } - onKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional }); + onKeyUsagesChange({ + requiredUsages: newRequired, + optionalUsages: newOptional + }); }; - const handleExtendedKeyUsageChange = (usage: CertExtendedKeyUsageType, newState: UsageState) => { - const currentRequired = watchedExtendedKeyUsages.requiredUsages || []; - const currentOptional = watchedExtendedKeyUsages.optionalUsages || []; + const handleExtendedKeyUsagePolicyChange = ( + usage: CertExtendedKeyUsageType, + policy: KeyUsagePolicy + ) => { + const newRequired = watchedExtendedKeyUsages.requiredUsages.filter((u) => u !== usage); + const newOptional = watchedExtendedKeyUsages.optionalUsages.filter((u) => u !== usage); - let newRequired = currentRequired.filter(u => u !== usage); - let newOptional = currentOptional.filter(u => u !== usage); - - if (newState === "mandatory") { - newRequired = [...newRequired, usage]; - } else if (newState === "optional") { - newOptional = [...newOptional, usage]; + if (policy === "require") { + newRequired.push(usage); + } else if (policy === "allow") { + newOptional.push(usage); } - onExtendedKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional }); + onExtendedKeyUsagesChange({ + requiredUsages: newRequired, + optionalUsages: newOptional + }); }; + const keyUsagePolicyOptions = [ + { value: "deny", label: "Deny" }, + { value: "allow", label: "Allow" }, + { value: "require", label: "Require" } + ]; + + const extendedKeyUsagePolicyOptions = [ + { value: "deny", label: "Deny" }, + { value: "allow", label: "Allow" }, + { value: "require", label: "Require" } + ]; + return ( -
-
-

Key Usages

-
- {KEY_USAGE_OPTIONS.map((usage) => ( - handleKeyUsageChange(usage, newState)} - /> - ))} +
+ {/* Key Usages */} +
+

Key Usages

+ +
+ {KEY_USAGE_OPTIONS.map((usage) => { + const policy = getKeyUsagePolicy(usage); + return ( +
+ {formatKeyUsage(usage)} + +
+ ); + })}
-
-

Extended Key Usages

-
- {EXTENDED_KEY_USAGE_OPTIONS.map((usage) => ( - handleExtendedKeyUsageChange(usage, newState)} - /> - ))} + {/* Extended Key Usages */} +
+

Extended Key Usages

+ +
+ {EXTENDED_KEY_USAGE_OPTIONS.map((usage) => { + const policy = getExtendedKeyUsagePolicy(usage); + return ( +
+ {formatExtendedKeyUsage(usage)} + +
+ ); + })}
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts index 474a093ce..4416cc680 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts @@ -3,93 +3,121 @@ import { z } from "zod"; import { CertDurationUnit, CertExtendedKeyUsageType, - CertIncludeType, CertKeyUsageType, CertSubjectAlternativeNameType, - CertSubjectAttributeType + CertSubjectAttributeType, + SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS, + SAN_INCLUDE_OPTIONS } from "./certificate-constants"; -export const attributeSchema = z.object({ +export const uiAttributeSchema = z.object({ type: z.nativeEnum(CertSubjectAttributeType), - include: z.nativeEnum(CertIncludeType), - value: z.array(z.string().min(1, "Value cannot be empty")).optional() + include: z.enum(SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS), + value: z.array(z.string().min(1, "Value cannot be empty")) }); -export const sanSchema = z.object({ +export const uiSanSchema = z.object({ type: z.nativeEnum(CertSubjectAlternativeNameType), - include: z.nativeEnum(CertIncludeType), - value: z.array(z.string().min(1, "Value cannot be empty")).optional() + include: z.enum(SAN_INCLUDE_OPTIONS), + value: z.array(z.string().min(1, "Value cannot be empty")) +}); + +export const uiKeyUsagesSchema = z.object({ + requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)), + optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)) +}); + +export const uiExtendedKeyUsagesSchema = z.object({ + requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)), + optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)) +}); + +export const uiValiditySchema = z.object({ + maxDuration: z.object({ + value: z.number().min(1, "Duration must be at least 1"), + unit: z.nativeEnum(CertDurationUnit) + }) +}); + +export const uiSignatureAlgorithmSchema = z.object({ + allowedAlgorithms: z.array(z.string()).optional(), + defaultAlgorithm: z.string().optional() +}); + +export const uiKeyAlgorithmSchema = z.object({ + allowedKeyTypes: z.array(z.string()).optional(), + defaultKeyType: z.string().optional() }); export const templateSchema = z.object({ slug: z.string().trim().min(1, "Template name is required"), description: z.string().optional(), - attributes: z.array(attributeSchema).optional().refine((attributes) => { - if (!attributes) return true; + attributes: z.array(uiAttributeSchema).optional(), + subjectAlternativeNames: z.array(uiSanSchema).optional(), + keyUsages: uiKeyUsagesSchema.optional(), + extendedKeyUsages: uiExtendedKeyUsagesSchema.optional(), + validity: uiValiditySchema.optional(), + signatureAlgorithm: uiSignatureAlgorithmSchema.optional(), + keyAlgorithm: uiKeyAlgorithmSchema.optional() +}); - const attributesByType = attributes.reduce((acc, attr) => { - if (!acc[attr.type]) acc[attr.type] = []; - acc[attr.type].push(attr); - return acc; - }, {} as Record); +export type TemplateFormData = z.infer; - for (const [, attrs] of Object.entries(attributesByType)) { - const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory'); +export const apiSubjectSchema = z + .object({ + type: z.nativeEnum(CertSubjectAttributeType), + allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().min(1, "Value cannot be empty")).optional() + }) + .refine((data) => data.allowed || data.required || data.denied, { + message: "At least one allowed, required, or denied value must be provided" + }); - if (mandatoryAttrs.length > 1) { - return false; - } +export const apiSanSchema = z + .object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().min(1, "Value cannot be empty")).optional() + }) + .refine((data) => data.allowed || data.required || data.denied, { + message: "At least one allowed, required, or denied value must be provided" + }); - if (mandatoryAttrs.length === 1 && attrs.length > 1) { - return false; - } - } - - return true; - }, { - message: "Attribute validation failed: when a mandatory value exists, no other values are allowed for that attribute type" - }), +export const apiTemplateSchema = z.object({ + name: z.string().trim().min(1, "Template name is required"), + description: z.string().optional(), + subject: z.array(apiSubjectSchema).optional(), + sans: z.array(apiSanSchema).optional(), keyUsages: z .object({ - requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), - optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional() + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() }) .optional(), extendedKeyUsages: z .object({ - requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), - optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .optional(), + algorithms: z + .object({ + signature: z.array(z.string()).optional(), + keyAlgorithm: z.array(z.string()).optional() }) .optional(), - subjectAlternativeNames: z.array(sanSchema).optional(), validity: z .object({ - maxDuration: z - .object({ - value: z.number().min(1, "Duration must be at least 1"), - unit: z.nativeEnum(CertDurationUnit) - }) - .optional(), - minDuration: z - .object({ - value: z.number().min(1, "Duration must be at least 1"), - unit: z.nativeEnum(CertDurationUnit) - }) + max: z + .string() + .regex(/^\d+[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'") .optional() }) - .optional(), - signatureAlgorithm: z - .object({ - allowedAlgorithms: z.array(z.string()).optional(), - defaultAlgorithm: z.string().optional() - }) - .optional(), - keyAlgorithm: z - .object({ - allowedKeyTypes: z.array(z.string()).optional(), - defaultKeyType: z.string().optional() - }) .optional() }); -export type TemplateFormData = z.infer; +export type ApiTemplateFormData = z.infer; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts index c6a3975da..427269557 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts @@ -2,7 +2,9 @@ import { CertExtendedKeyUsageType, CertKeyUsageType, formatExtendedKeyUsage, - formatKeyUsage + formatKeyUsage, + USAGE_STATES, + UsageState } from "./certificate-constants"; export const formatUsageName = (usage: string): string => { @@ -14,6 +16,7 @@ export const formatUsageName = (usage: string): string => { return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType); } } catch { + // Handle any errors in type checking } return usage.replace(/_/g, " "); }; @@ -22,15 +25,15 @@ export const getUsageState = ( usage: CertKeyUsageType | CertExtendedKeyUsageType, requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[] -): "required" | "optional" | undefined => { - if (requiredUsages.includes(usage)) return "required"; - if (optionalUsages.includes(usage)) return "optional"; +): UsageState => { + if (requiredUsages.includes(usage)) return USAGE_STATES.REQUIRED; + if (optionalUsages.includes(usage)) return USAGE_STATES.OPTIONAL; return undefined; }; export const toggleUsageState = ( usage: CertKeyUsageType | CertExtendedKeyUsageType, - newState: "required" | "optional" | undefined, + newState: UsageState, currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void, @@ -39,10 +42,10 @@ export const toggleUsageState = ( const isRequired = currentRequiredUsages.includes(usage); const isOptional = currentOptionalUsages.includes(usage); - if (newState === "required") { + if (newState === USAGE_STATES.REQUIRED) { if (isOptional) toggleOptional(usage); if (!isRequired) toggleRequired(usage); - } else if (newState === "optional") { + } else if (newState === USAGE_STATES.OPTIONAL) { if (isRequired) toggleRequired(usage); if (!isOptional) toggleOptional(usage); } else {