From 933830e17eff79417bc12f1d547a016bb47539e0 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sat, 22 Nov 2025 00:33:30 -0300 Subject: [PATCH 1/4] Add self-sign certs support --- ...add-issuer-type-to-certificate-profiles.ts | 31 + .../db/schemas/pki-certificate-profiles.ts | 5 +- .../ee/services/audit-log/audit-log-types.ts | 1 + .../ee/services/pki-acme/pki-acme-service.ts | 11 + backend/src/server/routes/index.ts | 5 +- .../routes/v1/certificate-profiles-router.ts | 27 +- .../certificate-est-v3-service.ts | 18 + .../certificate-profile-dal.ts | 18 +- .../certificate-profile-schemas.ts | 42 +- .../certificate-profile-service.test.ts | 19 +- .../certificate-profile-service.ts | 51 +- .../certificate-profile-types.ts | 14 +- .../certificate-v3-service.test.ts | 97 ++- .../certificate-v3/certificate-v3-service.ts | 711 ++++++++++++++++-- .../certificate/certificate-service.ts | 16 + .../hooks/api/certificateProfiles/types.ts | 10 +- .../components/CertificatesTable.tsx | 2 +- .../CreateProfileModal.tsx | 117 ++- .../CertificateProfilesTab/ProfileList.tsx | 4 +- .../CertificateProfilesTab/ProfileRow.tsx | 21 +- 20 files changed, 1085 insertions(+), 135 deletions(-) create mode 100644 backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts diff --git a/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts new file mode 100644 index 000000000..d718d5c97 --- /dev/null +++ b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (!hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("issuerType").notNullable().defaultTo("ca"); + }); + } + + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("caId").nullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropColumn("issuerType"); + }); + } + + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("caId").notNullable().alter(); + }); +} diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 04560bec6..c0dd891f0 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiCertificateProfilesSchema = z.object({ id: z.string().uuid(), projectId: z.string(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z.string(), description: z.string().nullable().optional(), @@ -19,7 +19,8 @@ export const PkiCertificateProfilesSchema = z.object({ apiConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - acmeConfigId: z.string().uuid().nullable().optional() + acmeConfigId: z.string().uuid().nullable().optional(), + issuerType: z.string().default("ca") }); export type TPkiCertificateProfiles = z.infer; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ab5b126c6..ac7aa6404 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2787,6 +2787,7 @@ interface CreateCertificateProfile { name: string; projectId: string; enrollmentType: string; + issuerType: string; }; } diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 4f560ade7..c4fb0ab1c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -683,6 +683,13 @@ export const pkiAcmeServiceFactory = ({ payload: TFinalizeAcmeOrderPayload; }): Promise> => { const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!; + + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for ACME enrollment" + }); + } + let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); @@ -732,6 +739,10 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" }); } + if (!profile.caId) { + throw new NotFoundError({ message: "Self-signed certificates are not supported for ACME enrollment" }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2b2023eb2..00771168c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2219,7 +2219,10 @@ export const registerRoutes = async ( permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL, + certificateBodyDAL }); const certificateV3Queue = certificateV3QueueServiceFactory({ diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 5792c5e83..0f9fb2581 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -8,7 +8,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertStatus } from "@app/services/certificate/certificate-types"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { server.route({ @@ -23,7 +23,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid body: z .object({ projectId: z.string().min(1), - caId: z.string().uuid(), + caId: z.string().uuid().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -32,6 +32,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -82,11 +83,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid return false; } } + + if (data.issuerType === IssuerType.CA) { + if (!data.caId) { + return false; + } + } + if (data.issuerType === IssuerType.SELF_SIGNED) { + if (data.caId) { + return false; + } + if (data.enrollmentType !== EnrollmentType.API) { + return false; + } + } + return true; }, { message: - "EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration." + "EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration. CA issuer type requires a CA ID. Self-signed issuer type cannot have a CA ID and only supports API enrollment." } ), response: { @@ -115,7 +131,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid certificateProfileId: certificateProfile.id, name: certificateProfile.slug, projectId: certificateProfile.projectId, - enrollmentType: certificateProfile.enrollmentType + enrollmentType: certificateProfile.enrollmentType, + issuerType: certificateProfile.issuerType } } }); @@ -139,6 +156,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }), response: { @@ -339,6 +357,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index 0d8ef30d0..2499a18d2 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -67,6 +67,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -169,6 +175,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -281,6 +293,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index d2f468248..dc74e2ea4 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -7,6 +7,7 @@ import { ormify, selectAllTableCols } from "@app/lib/knex"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -198,6 +199,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, acmeConfigId: result.acmeConfigId, @@ -239,12 +241,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, tx?: Knex ): Promise => { try { - const { offset = 0, limit = 20, search, enrollmentType, caId } = options; + const { offset = 0, limit = 20, search, enrollmentType, issuerType, caId } = options; let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( `${TableName.PkiCertificateProfile}.projectId`, @@ -269,6 +272,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); } + if (issuerType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.issuerType`, issuerType); + } + const query = baseQuery .leftJoin( TableName.PkiEstEnrollmentConfig, @@ -338,8 +345,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, @@ -359,12 +368,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { options: { search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, tx?: Knex ): Promise => { try { - const { search, enrollmentType, caId } = options; + const { search, enrollmentType, issuerType, caId } = options; let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); @@ -384,6 +394,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { query = query.where({ caId }); } + if (issuerType) { + query = query.where({ issuerType }); + } + const result = await query.count("*").first(); return parseInt((result as unknown as { count: string }).count || "0", 10); } catch (error) { diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index bf88593bd..dc5637475 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -1,12 +1,17 @@ import RE2 from "re2"; import { z } from "zod"; -import { EnrollmentType } from "./certificate-profile-types"; +import { CertStatus } from "../certificate/certificate-types"; +import { EnrollmentType, IssuerType } from "./certificate-profile-types"; export const createCertificateProfileSchema = z .object({ projectId: z.string().uuid("Project ID must be valid"), - caId: z.string().uuid(), + caId: z + .union([z.string().uuid(), z.literal("")]) + .optional() + .nullable() + .transform((val) => (val === "" ? null : val)), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -15,6 +20,7 @@ export const createCertificateProfileSchema = z .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -32,6 +38,7 @@ export const createCertificateProfileSchema = z }) .refine( (data) => { + // Validate enrollment type configurations if (data.enrollmentType === EnrollmentType.EST) { if (!data.estConfig) { return false; @@ -65,11 +72,26 @@ export const createCertificateProfileSchema = z return false; } } + + if (data.issuerType === IssuerType.CA) { + if (!data.caId) { + return false; + } + } + if (data.issuerType === IssuerType.SELF_SIGNED) { + if (data.caId) { + return false; + } + if (data.enrollmentType !== EnrollmentType.API) { + return false; + } + } + return true; }, { message: - "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration. CA issuer type requires a CA ID. Self-signed issuer type cannot have a CA ID and only supports API enrollment." } ); @@ -83,6 +105,7 @@ export const updateCertificateProfileSchema = z .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -109,10 +132,18 @@ export const updateCertificateProfileSchema = z return false; } } + + if (data.issuerType === IssuerType.SELF_SIGNED) { + if (data.enrollmentType && data.enrollmentType !== EnrollmentType.API) { + return false; + } + } + return true; }, { - message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + message: + "Cannot have EST config with API enrollment type or API config with EST enrollment type. Self-signed issuer type only supports API enrollment." } ); @@ -131,6 +162,7 @@ export const listCertificateProfilesSchema = z.object({ limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }); @@ -142,6 +174,6 @@ export const listCertificatesByProfileSchema = z.object({ profileId: z.string().uuid(), offset: z.coerce.number().min(0).default(0), limit: z.coerce.number().min(1).max(100).default(20), - status: z.enum(["active", "expired", "revoked"]).optional(), + status: z.nativeEnum(CertStatus).optional(), search: z.string().optional() }); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 3b75c1088..122dde8cd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -22,7 +22,12 @@ import type { TKmsServiceFactory } from "../kms/kms-service"; import type { TProjectDALFactory } from "../project/project-dal"; import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; -import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; +import { + EnrollmentType, + IssuerType, + TCertificateProfile, + TCertificateProfileWithConfigs +} from "./certificate-profile-types"; vi.mock("@app/lib/crypto/cryptography", () => ({ crypto: { @@ -90,6 +95,7 @@ describe("CertificateProfileService", () => { description: "Test certificate profile", slug: "test-profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", @@ -272,6 +278,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -312,6 +319,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", @@ -383,6 +391,7 @@ describe("CertificateProfileService", () => { slug: "invalid-profile", description: "Invalid test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123" }; @@ -401,6 +410,7 @@ describe("CertificateProfileService", () => { slug: "api-profile", description: "Profile with API enrollment", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -726,6 +736,7 @@ describe("CertificateProfileService", () => { slug: "est-profile", description: "Profile with EST enrollment", enrollmentType: EnrollmentType.EST, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", estConfig: { @@ -776,6 +787,7 @@ describe("CertificateProfileService", () => { slug: "different-profile-name", description: "Profile with duplicate slug", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -801,6 +813,7 @@ describe("CertificateProfileService", () => { slug: "auto-renew-profile", description: "Profile with auto-renewal", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -965,6 +978,7 @@ describe("CertificateProfileService", () => { slug: "invalid-template-profile", description: "Profile with invalid template", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "nonexistent-template", apiConfig: { @@ -990,6 +1004,7 @@ describe("CertificateProfileService", () => { slug: "concurrent-profile", description: "Profile created concurrently", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1018,6 +1033,7 @@ describe("CertificateProfileService", () => { slug: "cross-project-profile", description: "Profile using template from different project", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-456", apiConfig: { @@ -1047,6 +1063,7 @@ describe("CertificateProfileService", () => { slug: "invalid-slug-profile", description: "Profile with invalid slug format", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 12e272ad6..0d36ab101 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -32,6 +32,7 @@ import { getProjectKmsCertificateKeyId } from "../project/project-fns"; import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -39,6 +40,34 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; +const validateIssuerTypeConstraints = ( + issuerType: IssuerType, + enrollmentType: EnrollmentType, + caId: string | null, + existingCaId?: string | null +) => { + if (issuerType === IssuerType.CA) { + if (!caId && !existingCaId) { + throw new ForbiddenRequestError({ + message: "CA issuer type requires a Certificate Authority to be selected" + }); + } + } + + if (issuerType === IssuerType.SELF_SIGNED) { + if (caId) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type cannot have a Certificate Authority" + }); + } + if (enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type only supports API enrollment" + }); + } + } +}; + const generateAndEncryptAcmeEabSecret = async ( projectId: string, kmsService: Pick, @@ -163,7 +192,8 @@ export type TCertificateProfileServiceFactory = ReturnType): TCertificateProfile => { return { ...dalResult, - enrollmentType: dalResult.enrollmentType as EnrollmentType + enrollmentType: dalResult.enrollmentType as EnrollmentType, + issuerType: dalResult.issuerType as IssuerType } as TCertificateProfile; }; @@ -240,6 +270,8 @@ export const certificateProfileServiceFactory = ({ }); } + validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); + // Validate enrollment configuration requirements if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { throw new ForbiddenRequestError({ @@ -376,7 +408,18 @@ export const certificateProfileServiceFactory = ({ } } - const { estConfig, apiConfig, ...profileUpdateData } = data; + const finalIssuerType = data.issuerType || existingProfile.issuerType; + const finalEnrollmentType = data.enrollmentType || existingProfile.enrollmentType; + const finalCaId = data.caId !== undefined ? data.caId : existingProfile.caId; + + validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); + + const updatedData = + finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId && data.issuerType === IssuerType.SELF_SIGNED + ? { ...data, caId: null } + : data; + + const { estConfig, apiConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -569,6 +612,7 @@ export const certificateProfileServiceFactory = ({ limit = 20, search, enrollmentType, + issuerType, caId }: { actor: ActorType; @@ -580,6 +624,7 @@ export const certificateProfileServiceFactory = ({ limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; }): Promise<{ profiles: TCertificateProfileWithConfigs[]; @@ -603,12 +648,14 @@ export const certificateProfileServiceFactory = ({ limit, search, enrollmentType, + issuerType, caId }); const totalCount = await certificateProfileDAL.countByProjectId(projectId, { search, enrollmentType, + issuerType, caId }); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 030548e97..85260b25d 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -10,16 +10,24 @@ export enum EnrollmentType { ACME = "acme" } -export type TCertificateProfile = Omit & { +export enum IssuerType { + CA = "ca", + SELF_SIGNED = "self-signed" +} + +export type TCertificateProfile = Omit & { enrollmentType: EnrollmentType; + issuerType: IssuerType; }; -export type TCertificateProfileInsert = Omit & { +export type TCertificateProfileInsert = Omit & { enrollmentType: EnrollmentType; + issuerType: IssuerType; }; -export type TCertificateProfileUpdate = Omit & { +export type TCertificateProfileUpdate = Omit & { enrollmentType?: EnrollmentType; + issuerType?: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 6c664e324..3214b1cbd 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -22,7 +22,7 @@ import { CertSubjectAttributeType } from "@app/services/certificate-common/certificate-constants"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; @@ -40,18 +40,29 @@ vi.mock("../certificate-common/certificate-csr-utils", () => ({ describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; - const mockCertificateDAL: Pick = { + const mockCertificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" + > = { findOne: vi.fn(), findById: vi.fn(), updateById: vi.fn(), + create: vi.fn().mockResolvedValue({ + id: "new-cert-id", + serialNumber: "123456789", + friendlyName: "Test Certificate", + commonName: "test.example.com", + status: "ACTIVE" + }), transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const mockTx = {}; return callback(mockTx); }) }; - const mockCertificateSecretDAL: Pick = { - findOne: vi.fn() + const mockCertificateSecretDAL: Pick = { + findOne: vi.fn(), + create: vi.fn() }; const mockCertificateAuthorityDAL: Pick = { @@ -150,7 +161,24 @@ describe("CertificateV3Service", () => { }, pkiSyncQueue: { queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined) - } + }, + certificateBodyDAL: { + create: vi.fn().mockResolvedValue({ id: "body-123" }) + }, + kmsService: { + generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"), + encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))), + decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))) + }, + projectDAL: { + findOne: vi.fn().mockResolvedValue({ id: "project-123" }), + findById: vi.fn().mockResolvedValue({ id: "project-123" }), + updateById: vi.fn().mockResolvedValue({ id: "project-123" }), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) + } as any }); }); @@ -175,6 +203,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -319,6 +348,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -508,6 +538,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -561,6 +592,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -721,6 +753,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -772,6 +805,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -933,6 +967,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -971,6 +1006,7 @@ describe("CertificateV3Service", () => { caId: "ca-1", certificateTemplateId: "template-1", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, createdAt: new Date(), updatedAt: new Date(), description: "Test profile for algorithm compatibility", @@ -1552,6 +1588,7 @@ describe("CertificateV3Service", () => { id: "profile-123", projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1733,9 +1770,9 @@ describe("CertificateV3Service", () => { }); }); - it("should reject renewal if certificate is not from a profile", async () => { - const certWithoutProfile = { ...mockOriginalCert, profileId: null }; - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + it("should reject renewal if certificate has no profile and no CA", async () => { + const certWithoutProfileAndCA = { ...mockOriginalCert, profileId: null, caId: null }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfileAndCA); // Set up transaction mock to properly handle errors vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { @@ -1755,7 +1792,7 @@ describe("CertificateV3Service", () => { certificateId: "cert-123", ...mockActor }) - ).rejects.toThrow("Only certificates issued from a profile can be renewed"); + ).rejects.toThrow("Only certificates issued from a profile or self-signed certificates can be renewed"); }); it("should reject renewal if certificate was issued from CSR (external private key)", async () => { @@ -1989,6 +2026,44 @@ describe("CertificateV3Service", () => { expect(result).toHaveProperty("certificate", "renewed-cert"); }); + + it("should successfully renew self-signed certificate", async () => { + // Self-signed certificate has no caId and no profileId + const selfSignedCert = { + ...mockOriginalCert, + profileId: null, + certificateTemplateId: null, + caId: null + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(selfSignedCert); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + + const newCert = { + ...selfSignedCert, + id: "cert-456", + serialNumber: "self-signed-789012" + }; + vi.mocked(mockCertificateDAL.create).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + + // Set up transaction mock + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + const result = await service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("serialNumber"); + }); }); describe("updateRenewalConfig", () => { @@ -2008,6 +2083,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2084,6 +2160,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2129,6 +2206,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2172,6 +2250,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index a537ddc06..81e3129de 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,8 +1,9 @@ import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; import { randomUUID } from "crypto"; import RE2 from "re2"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, TCertificates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCertificateActions, @@ -10,8 +11,11 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { @@ -28,12 +32,25 @@ import { TCertificateAuthorityWithAssociatedCa } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + createDistinguishedName, + createSerialNumber, + keyAlgorithmToAlgCfg, + signatureAlgorithmToAlgCfg +} from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; import { extractAlgorithmsFromCSR, extractCertificateRequestFromCSR @@ -68,8 +85,9 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; - certificateSecretDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; acmeAccountDAL: Pick; @@ -85,6 +103,8 @@ type TCertificateV3ServiceFactoryDep = { >; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -329,6 +349,141 @@ const parseTtlToDays = (ttl: string): number => { } }; +const generateSelfSignedCertificate = async ({ + certificateRequest, + template, + effectiveSignatureAlgorithm, + effectiveKeyAlgorithm +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + effectiveSignatureAlgorithm: CertSignatureAlgorithm; + effectiveKeyAlgorithm: CertKeyAlgorithm; +}): Promise<{ + certificate: Buffer; + privateKey: Buffer; + serialNumber: string; + notBefore: Date; + notAfter: Date; + certificateSubject: Record; + subjectAlternativeNames: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; +}> => { + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template?.sans + ); + + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const keyPair = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + const signatureAlgorithmConfig = signatureAlgorithmToAlgCfg(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + const notBeforeDate = certificateRequest.notBefore ? new Date(certificateRequest.notBefore) : new Date(); + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (certificateRequest.notAfter) { + notAfterDate = new Date(certificateRequest.notAfter); + } else if (certificateRequest.validity.ttl) { + notAfterDate = new Date(new Date().getTime() + ms(certificateRequest.validity.ttl)); + } + + const serialNumber = createSerialNumber(); + const dn = createDistinguishedName({ + commonName: certificateSubject.common_name, + organization: certificateSubject.organization, + ou: certificateSubject.organizational_unit, + country: certificateSubject.country, + province: certificateSubject.state_or_province_name, + locality: certificateSubject.locality_name + }); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: signatureAlgorithmConfig, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, false), + ...(certificateRequest.keyUsages?.length + ? [ + new x509.KeyUsagesExtension( + (convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce( + // eslint-disable-next-line no-bitwise + (acc: number, usage) => acc | x509.KeyUsageFlags[usage], + 0 + ), + false + ) + ] + : []), + ...(certificateRequest.extendedKeyUsages?.length + ? [ + new x509.ExtendedKeyUsageExtension( + (convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || []).map( + (eku) => x509.ExtendedKeyUsage[eku] + ), + false + ) + ] + : []), + ...(subjectAlternativeNames + ? [ + new x509.SubjectAlternativeNameExtension( + certificateRequest.altNames?.map((san) => ({ + type: san.type === CertSubjectAlternativeNameType.DNS_NAME ? "dns" : "ip", + value: san.value + })) || [], + false + ) + ] + : []) + ] + }); + + const certificatePem = cert.toString("pem"); + const privateKeyObj = crypto.nativeCrypto.KeyObject.from(keyPair.privateKey); + const privateKeyPem = privateKeyObj.export({ format: "pem", type: "pkcs8" }) as string; + + return { + certificate: Buffer.from(certificatePem), + privateKey: Buffer.from(privateKeyPem), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + certificateSubject, + subjectAlternativeNames: certificateRequest.altNames || [] + }; +}; + const calculateFinalRenewBeforeDays = ( profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, ttl: string, @@ -348,8 +503,248 @@ const calculateFinalRenewBeforeDays = ( return isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined; }; +const getEffectiveAlgorithms = ( + requestSignatureAlgorithm?: CertSignatureAlgorithm, + requestKeyAlgorithm?: CertKeyAlgorithm, + originalSignatureAlgorithm?: CertSignatureAlgorithm, + originalKeyAlgorithm?: CertKeyAlgorithm +) => { + return { + signatureAlgorithm: requestSignatureAlgorithm || originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256, + keyAlgorithm: requestKeyAlgorithm || originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048 + }; +}; + +const createSelfSignedCertificateRecord = async ({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal = false +}: { + selfSignedResult: Awaited>; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + }; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + certificateDAL: Pick; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + originalCert?.commonName || + (isRenewal ? "Renewed Self-signed Certificate" : "Self-signed Certificate"); + + const altNamesList = selfSignedResult.subjectAlternativeNames.map((san) => san.value).join(","); + + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const baseRecord = { + serialNumber: selfSignedResult.serialNumber, + friendlyName: originalCert?.friendlyName || subjectCommonName, + commonName: subjectCommonName, + altNames: altNamesList, + status: CertStatus.ACTIVE, + notBefore: selfSignedResult.notBefore, + notAfter: selfSignedResult.notAfter, + projectId, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + profileId: profile?.id || null + }; + + const renewalRecord = + isRenewal && originalCert + ? { + renewedFromCertificateId: originalCert.id + } + : {}; + + return certificateDAL.create( + { + ...baseRecord, + ...renewalRecord + }, + tx + ); +}; + +const createEncryptedCertificateData = async ({ + certificateId, + certificate, + privateKey, + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx +}: { + certificateId: string; + certificate: Buffer; + privateKey: Buffer; + projectId: string; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; +}) => { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ kmsId: certificateManagerKeyId }); + + const encryptedCertificate = await kmsEncryptor({ + plainText: certificate + }); + + await certificateBodyDAL.create( + { + certId: certificateId, + encryptedCertificate: encryptedCertificate.cipherTextBlob + }, + tx + ); + + const encryptedPrivateKey = await kmsEncryptor({ + plainText: privateKey + }); + + await certificateSecretDAL.create( + { + certId: certificateId, + encryptedPrivateKey: encryptedPrivateKey.cipherTextBlob + }, + tx + ); +}; + +const processSelfSignedCertificate = async ({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal = false +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + effectiveAlgorithms: { + signatureAlgorithm: CertSignatureAlgorithm; + keyAlgorithm: CertKeyAlgorithm; + }; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const selfSignedResult = await generateSelfSignedCertificate({ + certificateRequest, + template, + effectiveSignatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + effectiveKeyAlgorithm: effectiveAlgorithms.keyAlgorithm + }); + + const certificateData = await createSelfSignedCertificateRecord({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal + }); + + await certificateDAL.updateById( + certificateData.id, + { + signatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + keyAlgorithm: effectiveAlgorithms.keyAlgorithm + }, + tx + ); + + await createEncryptedCertificateData({ + certificateId: certificateData.id, + certificate: Buffer.from(selfSignedResult.certificate), + privateKey: Buffer.from(selfSignedResult.privateKey), + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + + return { + selfSignedResult, + certificateData + }; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, + certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, @@ -359,7 +754,9 @@ export const certificateV3ServiceFactory = ({ permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -416,15 +813,6 @@ export const certificateV3ServiceFactory = ({ }); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); - } - - validateCaSupport(ca, "direct certificate issuance"); - - validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; @@ -440,12 +828,76 @@ export const certificateV3ServiceFactory = ({ }); } - const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( { subjectAlternativeNames: certificateRequest.altNames }, - template.sans + template?.sans ); + const issuerType = profile?.issuerType || (profile?.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + if (issuerType === IssuerType.SELF_SIGNED) { + const result = await certificateDAL.transaction(async (tx) => { + const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + return processSelfSignedCertificate({ + certificateRequest, + template, + profile, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + }); + + const { selfSignedResult, certificateData } = result; + + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + "Self-signed Certificate"; + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + selfSignedResult.notAfter + ); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById(certificateData.id, { + renewBeforeDays: finalRenewBeforeDays + }); + } + + return { + certificate: selfSignedResult.certificate.toString("utf8"), + issuingCaCertificate: "", + certificateChain: selfSignedResult.certificate.toString("utf8"), + privateKey: selfSignedResult.privateKey.toString("utf8"), + serialNumber: selfSignedResult.serialNumber, + certificateId: certificateData.id, + projectId: profile.projectId, + profileName: profile.slug, + commonName: subjectCommonName + }; + } + + if (!profile.caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + validateAlgorithmCompatibility(ca, template); + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = await internalCaService.issueCertFromCa({ caId: ca.id, @@ -477,10 +929,11 @@ export const certificateV3ServiceFactory = ({ new Date(cert.notAfter) ); - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(cert.id, updateData); let finalCertificateChain = bufferToString(certificateChain); if (removeRootsFromChain) { @@ -525,6 +978,12 @@ export const certificateV3ServiceFactory = ({ enrollmentType ); + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for CSR signing" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -592,10 +1051,11 @@ export const certificateV3ServiceFactory = ({ const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); + const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData2.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(cert.id, updateData2); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); @@ -663,6 +1123,12 @@ export const certificateV3ServiceFactory = ({ }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for certificate ordering" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -725,9 +1191,10 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate not found" }); } - if (!originalCert.profileId) { + const isSelfSigned = !originalCert.profileId && !originalCert.caId && originalCert.certificateTemplateId === null; + if (!originalCert.profileId && !originalCert.caId && !isSelfSigned) { throw new ForbiddenRequestError({ - message: "Only certificates issued from a profile can be renewed" + message: "Only certificates issued from a profile or self-signed certificates can be renewed" }); } @@ -741,15 +1208,18 @@ export const certificateV3ServiceFactory = ({ }); } - const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } + let profile = null; + if (originalCert.profileId) { + profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } - if (profile.enrollmentType !== EnrollmentType.API) { - throw new ForbiddenRequestError({ - message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" - }); + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" + }); + } } const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx); @@ -761,10 +1231,11 @@ export const certificateV3ServiceFactory = ({ } if (!internal) { + const projectId = profile?.projectId || originalCert.projectId; const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: profile.projectId, + projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -776,33 +1247,46 @@ export const certificateV3ServiceFactory = ({ ); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); + const issuerType = profile?.issuerType || (originalCert.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + let ca; + if (issuerType === IssuerType.CA) { + const caId = profile?.caId || originalCert.caId; + if (!caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const eligibilityCheck = validateRenewalEligibility(originalCert, ca); + if (!eligibilityCheck.isEligible) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + throw new BadRequestError({ + message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + } + + validateCaSupport(ca, "direct certificate issuance"); } - const eligibilityCheck = validateRenewalEligibility(originalCert, ca); - if (!eligibilityCheck.isEligible) { - await certificateDAL.updateById(originalCert.id, { - renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` - }); - throw new BadRequestError({ - message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` - }); - } + const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; + const template = templateId + ? await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + internal + }) + : null; - validateCaSupport(ca, "direct certificate issuance"); - - const template = await certificateTemplateV2Service.getTemplateV2ById({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - templateId: profile.certificateTemplateId, - internal - }); - - if (!template) { + if (!template && profile) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); } @@ -857,10 +1341,13 @@ export const certificateV3ServiceFactory = ({ keyAlgorithm: originalCert.keyAlgorithm || undefined }; - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - profile.certificateTemplateId, - certificateRequest - ); + let validationResult: { isValid: boolean; errors: string[] } = { isValid: true, errors: [] }; + if (profile?.certificateTemplateId) { + validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + certificateRequest + ); + } if (!validationResult.isValid) { await certificateDAL.updateById(originalCert.id, { @@ -872,14 +1359,28 @@ export const certificateV3ServiceFactory = ({ }); } - validateAlgorithmCompatibility(ca, template); const notBefore = new Date(); const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); - const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter); + const finalRenewBeforeDays = profile ? calculateFinalRenewBeforeDays(profile, ttl, notAfter) : undefined; - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.issueCertFromCa({ + let certificate: string; + let certificateChain: string; + let issuingCaCertificate: string; + let serialNumber: string; + let newCert: TCertificates; + + if (issuerType === IssuerType.CA) { + // CA-signed certificate renewal + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); + } + + validateAlgorithmCompatibility(ca, { + algorithms: template?.algorithms + } as { algorithms?: { signature?: string[] } }); + + const caResult = await internalCaService.issueCertFromCa({ caId: ca.id, friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", commonName: originalCert.commonName || "", @@ -900,20 +1401,72 @@ export const certificateV3ServiceFactory = ({ tx }); - const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + certificate = caResult.certificate; + certificateChain = caResult.certificateChain; + issuingCaCertificate = caResult.issuingCaCertificate; + serialNumber = caResult.serialNumber; + + const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + if (!foundCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + newCert = foundCert; + } else { + // Self-signed certificate renewal + const effectiveAlgorithms = getEffectiveAlgorithms( + undefined, + undefined, + originalSignatureAlgorithm, + originalKeyAlgorithm + ); + + const selfSignedRenewalResult = await processSelfSignedCertificate({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal: true + }); + + certificate = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); + certificateChain = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); // Self-signed has no chain + issuingCaCertificate = ""; // No issuing CA for self-signed + serialNumber = selfSignedRenewalResult.selfSignedResult.serialNumber; + newCert = selfSignedRenewalResult.certificateData; + } + if (!newCert) { throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } - await certificateDAL.updateById( - newCert.id, - { - profileId: originalCert.profileId, - renewBeforeDays: finalRenewBeforeDays, + // For self-signed certificates, we already set the renewal data during creation + // For CA-signed certificates, we need to set it now + if (issuerType === IssuerType.CA) { + const renewalUpdateData: { + profileId: string | null; + renewedFromCertificateId: string; + renewBeforeDays?: number; + } = { + profileId: originalCert.profileId || null, renewedFromCertificateId: originalCert.id - }, - tx - ); + }; + + if (finalRenewBeforeDays !== undefined) { + renewalUpdateData.renewBeforeDays = finalRenewBeforeDays; + } + + await certificateDAL.updateById(newCert.id, renewalUpdateData, tx); + } else if (finalRenewBeforeDays !== undefined) { + // For self-signed certificates, just update the renewBeforeDays if needed + await certificateDAL.updateById(newCert.id, { renewBeforeDays: finalRenewBeforeDays }, tx); + } await certificateDAL.updateById( originalCert.id, @@ -953,8 +1506,8 @@ export const certificateV3ServiceFactory = ({ certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, - projectId: renewalResult.profile.projectId, - profileName: renewalResult.profile.slug, + projectId: renewalResult.originalCert.projectId, + profileName: renewalResult.profile?.slug || "Self-signed Certificate", commonName: renewalResult.originalCert.commonName || "" }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index b632e76fb..515ee3ee0 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -309,6 +309,14 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, @@ -599,6 +607,14 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index c2b38e8e8..35e50a9f4 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -1,11 +1,12 @@ export type TCertificateProfile = { id: string; projectId: string; - caId: string; + caId: string | null; certificateTemplateId: string; slug: string; description?: string; enrollmentType: "api" | "est" | "acme"; + issuerType: "ca" | "self-signed"; estConfigId?: string; apiConfigId?: string; createdAt: string; @@ -44,11 +45,12 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { export type TCreateCertificateProfileDTO = { projectId: string; - caId: string; + caId?: string; certificateTemplateId: string; slug: string; description?: string; enrollmentType: "api" | "est" | "acme"; + issuerType: "ca" | "self-signed"; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase: string; @@ -65,6 +67,8 @@ export type TUpdateCertificateProfileDTO = { profileId: string; slug?: string; description?: string; + enrollmentType?: "api" | "est" | "acme"; + issuerType?: "ca" | "self-signed"; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; @@ -88,6 +92,8 @@ export type TListCertificateProfilesDTO = { search?: string; includeConfigs?: boolean; enrollmentType?: "api" | "est" | "acme"; + issuerType?: "ca" | "self-signed"; + caId?: string; }; export type TGetCertificateProfileByIdDTO = { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index a60142762..39b43129c 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -416,7 +416,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */} {(() => { const canRenew = - certificate.profileId && + (certificate.profileId || certificate.caId) && certificate.hasPrivateKey !== false && !certificate.renewedByCertificateId && !isRevoked && 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 516b64288..612fde634 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -47,7 +47,8 @@ const createSchema = z .max(1000, "Description must be less than 1000 characters") .optional(), enrollmentType: z.enum(["api", "est", "acme"]), - certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + issuerType: z.enum(["ca", "self-signed"]), + certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().min(1, "Certificate Template is required"), estConfig: z .object({ @@ -87,10 +88,21 @@ const createSchema = z if (data.enrollmentType === "acme" && !data.acmeConfig) { return false; } + + if (data.issuerType === "ca" && !data.certificateAuthorityId) { + return false; + } + if (data.issuerType === "self-signed" && data.certificateAuthorityId) { + return false; + } + if (data.issuerType === "self-signed" && data.enrollmentType !== "api") { + return false; + } + return true; }, { - message: "Configuration is required for selected enrollment type" + message: "Configuration is required for selected enrollment type and issuer type" } ); @@ -111,7 +123,8 @@ const editSchema = z .max(1000, "Description must be less than 1000 characters") .optional(), enrollmentType: z.enum(["api", "est", "acme"]), - certificateAuthorityId: z.string().optional(), + issuerType: z.enum(["ca", "self-signed"]), + certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().optional(), estConfig: z .object({ @@ -139,10 +152,22 @@ const editSchema = z if (data.enrollmentType === "acme" && !data.acmeConfig) { return false; } + + if (data.issuerType === "ca" && !data.certificateAuthorityId) { + return false; + } + if (data.issuerType === "self-signed" && data.certificateAuthorityId) { + return false; + } + if (data.issuerType === "self-signed" && data.enrollmentType !== "api") { + return false; + } + return true; }, { - message: "Configuration is required for selected enrollment type" + message: + "Configuration is required for selected enrollment type and issuer type. CA issuer requires a certificate authority. Self-signed issuer cannot have a certificate authority and only supports API enrollment." } ); @@ -193,7 +218,8 @@ export const CreateProfileModal = ({ slug: profile.slug, description: profile.description || "", enrollmentType: profile.enrollmentType, - certificateAuthorityId: profile.caId, + issuerType: profile.issuerType, + certificateAuthorityId: profile.caId || undefined, certificateTemplateId: profile.certificateTemplateId, estConfig: profile.enrollmentType === "est" @@ -217,6 +243,7 @@ export const CreateProfileModal = ({ slug: "", description: "", enrollmentType: "api", + issuerType: "ca", certificateAuthorityId: "", certificateTemplateId: "", apiConfig: { @@ -228,6 +255,7 @@ export const CreateProfileModal = ({ }); const watchedEnrollmentType = watch("enrollmentType"); + const watchedIssuerType = watch("issuerType"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedAutoRenew = watch("apiConfig.autoRenew"); @@ -237,7 +265,8 @@ export const CreateProfileModal = ({ slug: profile.slug, description: profile.description || "", enrollmentType: profile.enrollmentType, - certificateAuthorityId: profile.caId, + issuerType: profile.issuerType, + certificateAuthorityId: profile.caId || undefined, certificateTemplateId: profile.certificateTemplateId, estConfig: profile.enrollmentType === "est" @@ -276,7 +305,8 @@ export const CreateProfileModal = ({ const updateData: TUpdateCertificateProfileDTO = { profileId: profile.id, slug: data.slug, - description: data.description + description: data.description, + issuerType: data.issuerType }; if (data.enrollmentType === "est" && data.estConfig) { @@ -298,7 +328,9 @@ export const CreateProfileModal = ({ slug: data.slug, description: data.description, enrollmentType: data.enrollmentType, - caId: data.certificateAuthorityId, + issuerType: data.issuerType, + caId: + data.issuerType === "self-signed" ? undefined : data.certificateAuthorityId || undefined, certificateTemplateId: data.certificateTemplateId }; @@ -372,34 +404,73 @@ export const CreateProfileModal = ({ ( )} /> + {watchedIssuerType === "ca" && ( + ( + + + + )} + /> + )} + API - EST - ACME + + EST + + + ACME + )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx index 7b1ac187a..ed042c07e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx @@ -47,6 +47,7 @@ export const ProfileList = ({ Name Enrollment Method + Issuer Type Issuing CA Certificate Template @@ -54,7 +55,7 @@ export const ProfileList = ({ - + @@ -71,6 +72,7 @@ export const ProfileList = ({ Name Enrollment Method + Issuer Type Issuing CA Certificate Template 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 009ebe1a4..df2a15a7c 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -49,7 +49,7 @@ export const ProfileRow = ({ }: Props) => { const { permission } = useProjectPermission(); - const { data: caData } = useGetCaById(profile.caId); + const { data: caData } = useGetCaById(profile.caId || ""); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const); @@ -106,6 +106,20 @@ export const ProfileRow = ({ return {label}; }; + const getIssuerTypeBadge = (issuerType: string) => { + const config = { + ca: { variant: "success" as const, label: "CA" }, + "self-signed": { variant: "info" as const, label: "Self-Signed" } + } as const; + + const configKey = Object.keys(config).includes(issuerType) + ? (issuerType as keyof typeof config) + : "ca"; + const { variant, label } = config[configKey]; + + return {label}; + }; + return ( @@ -119,9 +133,12 @@ export const ProfileRow = ({ {getEnrollmentTypeBadge(profile.enrollmentType)} + {getIssuerTypeBadge(profile.issuerType)} - {caData?.friendlyName || caData?.commonName || profile.caId} + {profile.issuerType === "self-signed" + ? "—" + : caData?.friendlyName || caData?.commonName || profile.caId} From 0f32784834a415d0dc62317243d9964114f2ca94 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sat, 22 Nov 2025 00:41:11 -0300 Subject: [PATCH 2/4] Remove redundant test --- .../certificate-v3-service.test.ts | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 3214b1cbd..f4c23616e 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -2026,44 +2026,6 @@ describe("CertificateV3Service", () => { expect(result).toHaveProperty("certificate", "renewed-cert"); }); - - it("should successfully renew self-signed certificate", async () => { - // Self-signed certificate has no caId and no profileId - const selfSignedCert = { - ...mockOriginalCert, - profileId: null, - certificateTemplateId: null, - caId: null - }; - - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(selfSignedCert); - vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); - vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); - vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); - - const newCert = { - ...selfSignedCert, - id: "cert-456", - serialNumber: "self-signed-789012" - }; - vi.mocked(mockCertificateDAL.create).mockResolvedValue(newCert); - vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); - vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); - - // Set up transaction mock - vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { - const mockTx = {}; - return callback(mockTx); - }); - - const result = await service.renewCertificate({ - certificateId: "cert-123", - ...mockActor - }); - - expect(result).toHaveProperty("certificate"); - expect(result).toHaveProperty("serialNumber"); - }); }); describe("updateRenewalConfig", () => { From c0060c1d220b321cb8806a82a41abc54b7e738aa Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Sat, 22 Nov 2025 01:14:10 -0300 Subject: [PATCH 3/4] Address greptile comments --- ...20251121124532_add-issuer-type-to-certificate-profiles.ts | 4 ---- .../certificate-profile/certificate-profile-service.ts | 4 +--- .../services/certificate-v3/certificate-v3-service.test.ts | 2 +- .../src/services/certificate-v3/certificate-v3-service.ts | 5 ++--- .../components/CertificateProfilesTab/ProfileRow.tsx | 2 +- 5 files changed, 5 insertions(+), 12 deletions(-) diff --git a/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts index d718d5c97..61dcdb12e 100644 --- a/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts +++ b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts @@ -24,8 +24,4 @@ export async function down(knex: Knex): Promise { t.dropColumn("issuerType"); }); } - - await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { - t.uuid("caId").notNullable().alter(); - }); } diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 0d36ab101..0ce3ca9b6 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -415,9 +415,7 @@ export const certificateProfileServiceFactory = ({ validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); const updatedData = - finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId && data.issuerType === IssuerType.SELF_SIGNED - ? { ...data, caId: null } - : data; + finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; const { estConfig, apiConfig, ...profileUpdateData } = updatedData; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index f4c23616e..93291b051 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -1792,7 +1792,7 @@ describe("CertificateV3Service", () => { certificateId: "cert-123", ...mockActor }) - ).rejects.toThrow("Only certificates issued from a profile or self-signed certificates can be renewed"); + ).rejects.toThrow("Only certificates issued from a profile can be renewed"); }); it("should reject renewal if certificate was issued from CSR (external private key)", async () => { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 81e3129de..b4bad0f76 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1191,10 +1191,9 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate not found" }); } - const isSelfSigned = !originalCert.profileId && !originalCert.caId && originalCert.certificateTemplateId === null; - if (!originalCert.profileId && !originalCert.caId && !isSelfSigned) { + if (!originalCert.profileId) { throw new ForbiddenRequestError({ - message: "Only certificates issued from a profile or self-signed certificates can be renewed" + message: "Only certificates issued from a profile can be renewed" }); } 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 df2a15a7c..60057d40e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -49,7 +49,7 @@ export const ProfileRow = ({ }: Props) => { const { permission } = useProjectPermission(); - const { data: caData } = useGetCaById(profile.caId || ""); + const { data: caData } = useGetCaById(profile.caId ?? ""); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const); From d69a2db80c9f1cb5b9e1a58cf3e1954a928b5f42 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 24 Nov 2025 11:20:21 -0300 Subject: [PATCH 4/4] Minor improvements on self signed certificates --- .../ee/services/pki-acme/pki-acme-service.ts | 6 +- .../routes/v1/certificate-profiles-router.ts | 138 ++++++--- .../certificate-profile-schemas.ts | 186 +++++++----- .../certificate-v3/certificate-v3-service.ts | 59 +++- .../hooks/api/certificateProfiles/index.ts | 2 +- .../hooks/api/certificateProfiles/types.ts | 27 +- .../components/CertificateIssuanceModal.tsx | 4 +- .../CreateProfileModal.tsx | 277 +++++++++++++----- .../CertificateProfilesTab/ProfileList.tsx | 8 +- .../CertificateProfilesTab/ProfileRow.tsx | 21 +- 10 files changed, 491 insertions(+), 237 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index c4fb0ab1c..d24ed5394 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -739,11 +739,7 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" }); } - if (!profile.caId) { - throw new NotFoundError({ message: "Self-signed certificates are not supported for ACME enrollment" }); - } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId!); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); } diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 0f9fb2581..b23dd1fee 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -51,58 +51,100 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } + return !!data.estConfig; } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.ACME) { - if (!data.acmeConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - } - - if (data.issuerType === IssuerType.CA) { - if (!data.caId) { - return false; - } - } - if (data.issuerType === IssuerType.SELF_SIGNED) { - if (data.caId) { - return false; - } - if (data.enrollmentType !== EnrollmentType.API) { - return false; - } - } - return true; }, { - message: - "EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration. CA issuer type requires a CA ID. Self-signed issuer type cannot have a CA ID and only supports API enrollment." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ), response: { diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index dc5637475..e6b574dea 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -7,11 +7,7 @@ import { EnrollmentType, IssuerType } from "./certificate-profile-types"; export const createCertificateProfileSchema = z .object({ projectId: z.string().uuid("Project ID must be valid"), - caId: z - .union([z.string().uuid(), z.literal("")]) - .optional() - .nullable() - .transform((val) => (val === "" ? null : val)), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -38,60 +34,101 @@ export const createCertificateProfileSchema = z }) .refine( (data) => { - // Validate enrollment type configurations if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } + return !!data.estConfig; } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.ACME) { - if (!data.acmeConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - } - - if (data.issuerType === IssuerType.CA) { - if (!data.caId) { - return false; - } - } - if (data.issuerType === IssuerType.SELF_SIGNED) { - if (data.caId) { - return false; - } - if (data.enrollmentType !== EnrollmentType.API) { - return false; - } - } - return true; }, { - message: - "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration. CA issuer type requires a CA ID. Self-signed issuer type cannot have a CA ID and only supports API enrollment." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -123,27 +160,34 @@ export const updateCertificateProfileSchema = z .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (data.apiConfig) { - return false; - } + return !data.apiConfig; } - if (data.enrollmentType === EnrollmentType.API) { - if (data.estConfig) { - return false; - } - } - - if (data.issuerType === IssuerType.SELF_SIGNED) { - if (data.enrollmentType && data.enrollmentType !== EnrollmentType.API) { - return false; - } - } - return true; }, { - message: - "Cannot have EST config with API enrollment type or API config with EST enrollment type. Self-signed issuer type only supports API enrollment." + message: "EST enrollment type cannot have API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.enrollmentType || data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index b4bad0f76..0fadbc8de 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -407,11 +407,16 @@ const generateSelfSignedCertificate = async ({ const signatureAlgorithmConfig = signatureAlgorithmToAlgCfg(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); const notBeforeDate = certificateRequest.notBefore ? new Date(certificateRequest.notBefore) : new Date(); - let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + + let notAfterDate: Date; if (certificateRequest.notAfter) { notAfterDate = new Date(certificateRequest.notAfter); } else if (certificateRequest.validity.ttl) { notAfterDate = new Date(new Date().getTime() + ms(certificateRequest.validity.ttl)); + } else { + throw new BadRequestError({ + message: "Either notAfter date or TTL must be provided for certificate validity" + }); } const serialNumber = createSerialNumber(); @@ -458,10 +463,22 @@ const generateSelfSignedCertificate = async ({ ...(subjectAlternativeNames ? [ new x509.SubjectAlternativeNameExtension( - certificateRequest.altNames?.map((san) => ({ - type: san.type === CertSubjectAlternativeNameType.DNS_NAME ? "dns" : "ip", - value: san.value - })) || [], + certificateRequest.altNames?.map((san) => { + switch (san.type) { + case CertSubjectAlternativeNameType.DNS_NAME: + return { type: "dns" as const, value: san.value }; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return { type: "ip" as const, value: san.value }; + case CertSubjectAlternativeNameType.EMAIL: + return { type: "email" as const, value: san.value }; + case CertSubjectAlternativeNameType.URI: + return { type: "url" as const, value: san.value }; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + }) || [], false ) ] @@ -545,7 +562,7 @@ const createSelfSignedCertificateRecord = async ({ (selfSignedResult.certificateSubject.common_name as string) || certificateRequest.commonName || originalCert?.commonName || - (isRenewal ? "Renewed Self-signed Certificate" : "Self-signed Certificate"); + ""; const altNamesList = selfSignedResult.subjectAlternativeNames.map((san) => san.value).join(","); @@ -726,8 +743,8 @@ const processSelfSignedCertificate = async ({ await createEncryptedCertificateData({ certificateId: certificateData.id, - certificate: Buffer.from(selfSignedResult.certificate), - privateKey: Buffer.from(selfSignedResult.privateKey), + certificate: selfSignedResult.certificate, + privateKey: selfSignedResult.privateKey, projectId, certificateBodyDAL, certificateSecretDAL, @@ -1100,10 +1117,25 @@ export const certificateV3ServiceFactory = ({ commonName: certificateOrder.commonName, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, - value: san.value - })), + subjectAlternativeNames: certificateOrder.altNames.map((san) => { + let certType: CertSubjectAlternativeNameType; + switch (san.type) { + case "dns": + certType = CertSubjectAlternativeNameType.DNS_NAME; + break; + case "ip": + certType = CertSubjectAlternativeNameType.IP_ADDRESS; + break; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + return { + type: certType, + value: san.value + }; + }), validity: certificateOrder.validity, notBefore: certificateOrder.notBefore, notAfter: certificateOrder.notAfter, @@ -1216,7 +1248,8 @@ export const certificateV3ServiceFactory = ({ if (profile.enrollmentType !== EnrollmentType.API) { throw new ForbiddenRequestError({ - message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" + message: + "Certificate is not eligible for renewal: Only certificates issued from an API enrollment profile can be renewed through this endpoint" }); } } diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts index e12e066c4..27e73c15a 100644 --- a/frontend/src/hooks/api/certificateProfiles/index.ts +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -10,4 +10,4 @@ export { useGetProfileCertificates, useListCertificateProfiles } from "./queries"; -export type * from "./types"; +export * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 35e50a9f4..176a5dd9e 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -1,3 +1,14 @@ +export enum EnrollmentType { + API = "api", + EST = "est", + ACME = "acme" +} + +export enum IssuerType { + CA = "ca", + SELF_SIGNED = "self-signed" +} + export type TCertificateProfile = { id: string; projectId: string; @@ -5,8 +16,8 @@ export type TCertificateProfile = { certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est" | "acme"; - issuerType: "ca" | "self-signed"; + enrollmentType: EnrollmentType; + issuerType: IssuerType; estConfigId?: string; apiConfigId?: string; createdAt: string; @@ -49,8 +60,8 @@ export type TCreateCertificateProfileDTO = { certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est" | "acme"; - issuerType: "ca" | "self-signed"; + enrollmentType: EnrollmentType; + issuerType: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase: string; @@ -67,8 +78,8 @@ export type TUpdateCertificateProfileDTO = { profileId: string; slug?: string; description?: string; - enrollmentType?: "api" | "est" | "acme"; - issuerType?: "ca" | "self-signed"; + enrollmentType?: EnrollmentType; + issuerType?: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; @@ -91,8 +102,8 @@ export type TListCertificateProfilesDTO = { offset?: number; search?: string; includeConfigs?: boolean; - enrollmentType?: "api" | "est" | "acme"; - issuerType?: "ca" | "self-signed"; + enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; }; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 49ffe3494..5f6462d2e 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -21,7 +21,7 @@ import { import { useProject } from "@app/context"; import { useGetCert } from "@app/hooks/api"; import { useCreateCertificateV3 } from "@app/hooks/api/ca"; -import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; +import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -122,7 +122,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { data: profilesData } = useListCertificateProfiles({ projectId: currentProject?.id || "", - enrollmentType: "api" + enrollmentType: EnrollmentType.API }); const { mutateAsync: createCertificate } = useCreateCertificateV3({ 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 612fde634..044bc9cfc 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -21,6 +21,8 @@ import { import { useProject, useSubscription } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { + EnrollmentType, + IssuerType, TCertificateProfileWithDetails, TCreateCertificateProfileDTO, TUpdateCertificateProfileDTO, @@ -46,8 +48,8 @@ const createSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est", "acme"]), - issuerType: z.enum(["ca", "self-signed"]), + enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType), certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().min(1, "Certificate Template is required"), estConfig: z @@ -79,30 +81,101 @@ const createSchema = z }) .refine( (data) => { - if (data.enrollmentType === "est" && !data.estConfig) { - return false; + if (data.enrollmentType === EnrollmentType.EST) { + return !!data.estConfig; } - if (data.enrollmentType === "api" && !data.apiConfig) { - return false; - } - if (data.enrollmentType === "acme" && !data.acmeConfig) { - return false; - } - - if (data.issuerType === "ca" && !data.certificateAuthorityId) { - return false; - } - if (data.issuerType === "self-signed" && data.certificateAuthorityId) { - return false; - } - if (data.issuerType === "self-signed" && data.enrollmentType !== "api") { - return false; - } - return true; }, { - message: "Configuration is required for selected enrollment type and issuer type" + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.certificateAuthorityId; + } + return true; + }, + { + message: "CA issuer type requires a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.certificateAuthorityId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -122,8 +195,8 @@ const editSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est", "acme"]), - issuerType: z.enum(["ca", "self-signed"]), + enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType), certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().optional(), estConfig: z @@ -143,31 +216,101 @@ const editSchema = z }) .refine( (data) => { - if (data.enrollmentType === "est" && !data.estConfig) { - return false; + if (data.enrollmentType === EnrollmentType.EST) { + return !!data.estConfig; } - if (data.enrollmentType === "api" && !data.apiConfig) { - return false; - } - if (data.enrollmentType === "acme" && !data.acmeConfig) { - return false; - } - - if (data.issuerType === "ca" && !data.certificateAuthorityId) { - return false; - } - if (data.issuerType === "self-signed" && data.certificateAuthorityId) { - return false; - } - if (data.issuerType === "self-signed" && data.enrollmentType !== "api") { - return false; - } - return true; }, { - message: - "Configuration is required for selected enrollment type and issuer type. CA issuer requires a certificate authority. Self-signed issuer cannot have a certificate authority and only supports API enrollment." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.certificateAuthorityId; + } + return true; + }, + { + message: "CA issuer type requires a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.certificateAuthorityId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -222,7 +365,7 @@ export const CreateProfileModal = ({ certificateAuthorityId: profile.caId || undefined, certificateTemplateId: profile.certificateTemplateId, estConfig: - profile.enrollmentType === "est" + profile.enrollmentType === EnrollmentType.EST ? { disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false, @@ -231,19 +374,19 @@ export const CreateProfileModal = ({ } : undefined, apiConfig: - profile.enrollmentType === "api" + profile.enrollmentType === EnrollmentType.API ? { autoRenew: profile.apiConfig?.autoRenew || false, renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === "acme" ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined } : { slug: "", description: "", - enrollmentType: "api", - issuerType: "ca", + enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, certificateAuthorityId: "", certificateTemplateId: "", apiConfig: { @@ -284,13 +427,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === "acme" ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined }); } }, [isEdit, profile, reset]); const onFormSubmit = async (data: FormData) => { - if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === "acme") { + if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { reset(); onClose(); handlePopUpOpen("upgradePlan", { @@ -309,11 +452,11 @@ export const CreateProfileModal = ({ issuerType: data.issuerType }; - if (data.enrollmentType === "est" && data.estConfig) { + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { updateData.estConfig = data.estConfig; - } else if (data.enrollmentType === "api" && data.apiConfig) { + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { updateData.apiConfig = data.apiConfig; - } else if (data.enrollmentType === "acme" && data.acmeConfig) { + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { updateData.acmeConfig = data.acmeConfig; } @@ -330,19 +473,21 @@ export const CreateProfileModal = ({ enrollmentType: data.enrollmentType, issuerType: data.issuerType, caId: - data.issuerType === "self-signed" ? undefined : data.certificateAuthorityId || undefined, + data.issuerType === IssuerType.SELF_SIGNED + ? undefined + : data.certificateAuthorityId || undefined, certificateTemplateId: data.certificateTemplateId }; - if (data.enrollmentType === "est" && data.estConfig) { + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { createData.estConfig = { passphrase: data.estConfig.passphrase, caChain: data.estConfig.caChain || undefined, disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation }; - } else if (data.enrollmentType === "api" && data.apiConfig) { + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { createData.apiConfig = data.apiConfig; - } else if (data.enrollmentType === "acme" && data.acmeConfig) { + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { createData.acmeConfig = data.acmeConfig; } @@ -417,7 +562,7 @@ export const CreateProfileModal = ({ onValueChange={(value) => { if (value === "self-signed") { setValue("certificateAuthorityId", ""); - setValue("enrollmentType", "api"); + setValue("enrollmentType", EnrollmentType.API); setValue("apiConfig", { autoRenew: false, renewBeforeDays: 30 @@ -559,12 +704,12 @@ export const CreateProfileModal = ({ isDisabled={Boolean(isEdit)} > API - - EST - - - ACME - + {watchedIssuerType !== IssuerType.SELF_SIGNED && ( + EST + )} + {watchedIssuerType !== IssuerType.SELF_SIGNED && ( + ACME + )} )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx index ed042c07e..0ba217ce9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx @@ -47,7 +47,6 @@ export const ProfileList = ({ Name Enrollment Method - Issuer Type Issuing CA Certificate Template @@ -55,7 +54,7 @@ export const ProfileList = ({ - + @@ -72,17 +71,16 @@ export const ProfileList = ({ Name Enrollment Method - Issuer Type Issuing CA Certificate Template - {isLoading && } + {isLoading && } {!isLoading && (!profiles || profiles.length === 0) && ( - + 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 60057d40e..c2ec7b688 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -30,7 +30,7 @@ import { } 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 { IssuerType, TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; @@ -106,20 +106,6 @@ export const ProfileRow = ({ return {label}; }; - const getIssuerTypeBadge = (issuerType: string) => { - const config = { - ca: { variant: "success" as const, label: "CA" }, - "self-signed": { variant: "info" as const, label: "Self-Signed" } - } as const; - - const configKey = Object.keys(config).includes(issuerType) - ? (issuerType as keyof typeof config) - : "ca"; - const { variant, label } = config[configKey]; - - return {label}; - }; - return ( @@ -133,11 +119,10 @@ export const ProfileRow = ({ {getEnrollmentTypeBadge(profile.enrollmentType)} - {getIssuerTypeBadge(profile.issuerType)} - {profile.issuerType === "self-signed" - ? "—" + {profile.issuerType === IssuerType.SELF_SIGNED + ? "Self-signed" : caData?.friendlyName || caData?.commonName || profile.caId}