diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index f139ff2c1..d303859bb 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseSecretSyncs: false, enterpriseCertificateSyncs: false, enterpriseAppConnections: true, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2a3cf82cc..aba2c5e78 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -67,6 +67,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ fips: false, eventSubscriptions: false, machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, pam: false }); diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9cdcfcc3d..2276dcf36 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; + pkiLegacyTemplates: false; fips: false; eventSubscriptions: false; pam: false; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 5afbce96f..98e8c30a6 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -69,10 +69,24 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.PkiCertificateProfiles], - querystring: listCertificateProfilesSchema, + querystring: listCertificateProfilesSchema.extend({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), response: { 200: z.object({ - certificateProfiles: CertificateProfilesSchema.array(), + certificateProfiles: CertificateProfilesSchema.extend({ + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional() + }).array(), totalCount: z.number() }) } @@ -112,6 +126,10 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid hide: false, tags: [ApiDocsTags.PkiCertificateProfiles], params: getCertificateProfileByIdSchema, + querystring: z.object({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), response: { 200: z.object({ certificateProfile: CertificateProfilesSchema.extend({ @@ -145,6 +163,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), autoRenewDays: z.number().optional() }) + .optional(), + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) .optional() }) }) @@ -160,6 +188,20 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid profileId: req.params.id }); + let result = certificateProfile; + + if (req.query.includeMetrics) { + const metrics = await server.services.certificateProfile.getProfileMetrics({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + expiringDays: req.query.expiringDays + }); + result = { ...certificateProfile, metrics }; + } + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: certificateProfile.projectId, @@ -171,7 +213,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid } }); - return { certificateProfile }; + return { certificateProfile: result }; } }); @@ -322,7 +364,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid status: z.string(), notBefore: z.date(), notAfter: z.date(), - isRevoked: z.boolean(), + revokedAt: z.date().nullable().optional(), createdAt: z.date() }) ) @@ -343,45 +385,4 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid return { certificates }; } }); - - server.route({ - method: "GET", - url: "/:id/metrics", - config: { - rateLimit: readLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateProfiles], - params: getCertificateProfileByIdSchema, - querystring: z.object({ - expiringDays: z.number().min(1).max(365).default(30) - }), - response: { - 200: z.object({ - metrics: z.object({ - profileId: z.string(), - totalCertificates: z.number(), - activeCertificates: z.number(), - expiredCertificates: z.number(), - expiringCertificates: z.number(), - revokedCertificates: z.number() - }) - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const metrics = await server.services.certificateProfile.getProfileMetrics({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - profileId: req.params.id, - expiringDays: req.query.expiringDays - }); - - return { metrics }; - } - }); }; diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index eb85002ab..7aa19f900 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -33,14 +33,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), altNames: validateAltNamesField.optional(), - organization: z.string().optional(), - organizationUnit: z.string().optional(), - locality: z.string().optional(), - state: z.string().optional(), - country: z.string().length(2).optional(), - email: z.string().email().optional(), - streetAddress: z.string().optional(), - postalCode: z.string().optional(), signatureAlgorithm: z.string().optional(), keyAlgorithm: z.string().optional() }), @@ -65,14 +57,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => profileId: req.body.profileId, certificateRequest: { commonName: req.body.commonName, - organization: req.body.organization, - organizationUnit: req.body.organizationUnit, - locality: req.body.locality, - state: req.body.state, - country: req.body.country, - email: req.body.email, - streetAddress: req.body.streetAddress, - postalCode: req.body.postalCode, keyUsages: req.body.keyUsages, extendedKeyUsages: req.body.extendedKeyUsages, subjectAlternativeNames: req.body.altNames @@ -221,14 +205,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), commonName: validateTemplateRegexField.optional(), - organization: z.string().optional(), - organizationUnit: z.string().optional(), - locality: z.string().optional(), - state: z.string().optional(), - country: z.string().length(2).optional(), - email: z.string().email().optional(), - streetAddress: z.string().optional(), - postalCode: z.string().optional(), signatureAlgorithm: z.string().optional(), keyAlgorithm: z.string().optional() }), @@ -280,14 +256,6 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, commonName: req.body.commonName, - organization: req.body.organization, - organizationUnit: req.body.organizationUnit, - locality: req.body.locality, - state: req.body.state, - country: req.body.country, - email: req.body.email, - streetAddress: req.body.streetAddress, - postalCode: req.body.postalCode, keyUsages: req.body.keyUsages, extendedKeyUsages: req.body.extendedKeyUsages, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 6532f8588..2f692fba5 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -127,11 +127,21 @@ export const certificateProfileDALFactory = (db: TDbClient) => { search?: string; enrollmentType?: EnrollmentType; caId?: string; + includeMetrics?: boolean; + expiringDays?: number; } = {}, tx?: Knex ) => { try { - const { offset = 0, limit = 20, search, enrollmentType, caId } = options; + const { + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 7 + } = options; let query = (tx || db)(TableName.CertificateProfile).where( `${TableName.CertificateProfile}.projectId`, @@ -155,6 +165,38 @@ export const certificateProfileDALFactory = (db: TDbClient) => { query = query.where(`${TableName.CertificateProfile}.caId`, caId); } + if (includeMetrics) { + const now = new Date(); + const expiringDate = new Date(); + expiringDate.setDate(now.getDate() + expiringDays); + + const certificateProfiles = await query + .leftJoin(TableName.Certificate, `${TableName.CertificateProfile}.id`, `${TableName.Certificate}.profileId`) + .select( + selectAllTableCols(TableName.CertificateProfile), + db.raw("COUNT(certificates.id) as total_certificates"), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', + [expiringDate] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates', + [now] + ), + db.raw( + 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates', + [now, expiringDate] + ), + db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') + ) + .groupBy(`${TableName.CertificateProfile}.id`) + .orderBy(`${TableName.CertificateProfile}.createdAt`, "desc") + .offset(offset) + .limit(limit); + + return certificateProfiles; + } + const certificateProfiles = await query .select(selectAllTableCols(TableName.CertificateProfile)) .orderBy(`${TableName.CertificateProfile}.createdAt`, "desc") @@ -239,13 +281,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { if (status) { switch (status) { case "active": - query = query.where("notAfter", ">", now).where("isRevoked", false); + query = query.where("notAfter", ">", now).whereNull("revokedAt"); break; case "expired": - query = query.where("notAfter", "<=", now).where("isRevoked", false); + query = query.where("notAfter", "<=", now).whereNull("revokedAt"); break; case "revoked": - query = query.where("isRevoked", true); + query = query.whereNotNull("revokedAt"); break; default: break; @@ -259,7 +301,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { .select((tx || db).ref("status").withSchema(TableName.Certificate)) .select((tx || db).ref("notBefore").withSchema(TableName.Certificate)) .select((tx || db).ref("notAfter").withSchema(TableName.Certificate)) - .select((tx || db).ref("isRevoked").withSchema(TableName.Certificate)) + .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate)) .select((tx || db).ref("createdAt").withSchema(TableName.Certificate)) .orderBy("createdAt", "desc") .offset(offset) @@ -273,7 +315,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const getProfileMetrics = async ( profileId: string, - expiringDays: number = 30, + expiringDays: number = 7, tx?: Knex ): Promise => { try { @@ -285,13 +327,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => { .where("profileId", profileId) .select( db.raw("COUNT(*) as total_certificates"), - db.raw("COUNT(CASE WHEN NOT is_revoked AND not_after > ? THEN 1 END) as active_certificates", [now]), - db.raw("COUNT(CASE WHEN NOT is_revoked AND not_after <= ? THEN 1 END) as expired_certificates", [now]), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [ + expiringDate + ]), + db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]), db.raw( - "COUNT(CASE WHEN NOT is_revoked AND not_after > ? AND not_after <= ? THEN 1 END) as expiring_certificates", + 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates', [now, expiringDate] ), - db.raw("COUNT(CASE WHEN is_revoked THEN 1 END) as revoked_certificates") + db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') ) .first(); 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 42bdc0a2e..0f527bf8f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -466,7 +466,9 @@ describe("CertificateProfileService", () => { limit: 20, search: undefined, enrollmentType: undefined, - caId: undefined + caId: undefined, + includeMetrics: false, + expiringDays: 30 }); }); @@ -486,7 +488,51 @@ describe("CertificateProfileService", () => { limit: 5, search: "test", enrollmentType: EnrollmentType.API, - caId: "ca-123" + caId: "ca-123", + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with metrics when includeMetrics is true", async () => { + const mockProfilesWithMetrics = [ + { + ...sampleProfile, + total_certificates: 10, + active_certificates: 8, + expired_certificates: 1, + expiring_certificates: 1, + revoked_certificates: 0 + } + ]; + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics); + + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123", + includeMetrics: true, + expiringDays: 15 + }); + + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0]).toHaveProperty("metrics"); + expect(result.profiles[0].metrics).toEqual({ + profileId: sampleProfile.id, + totalCertificates: 10, + activeCertificates: 8, + expiredCertificates: 1, + expiringCertificates: 1, + revokedCertificates: 0 + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: true, + expiringDays: 15 }); }); }); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f51929865..45b073db0 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -23,7 +23,8 @@ import { TCertificateProfileInsert, TCertificateProfileMetrics, TCertificateProfileUpdate, - TCertificateProfileWithConfigs + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics } from "./certificate-profile-types"; export type TCertificateProfileCreateData = Omit & { @@ -48,10 +49,6 @@ const convertDalToService = (dalResult: Record): TCertificatePr } as TCertificateProfile; }; -const convertDalArrayToService = (dalResults: Record[]): TCertificateProfile[] => { - return dalResults.map(convertDalToService); -}; - const validateEnrollmentConfig = async (data: { enrollmentType: EnrollmentType; estConfig?: TEstConfigData | null; @@ -289,14 +286,18 @@ export const certificateProfileServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - profileId + profileId, + includeMetrics = false, + expiringDays = 30 }: { actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; actorOrgId: string; profileId: string; - }): Promise => { + includeMetrics?: boolean; + expiringDays?: number; + }): Promise => { const profile = await certificateProfileDAL.findById(profileId); if (!profile) { throw new NotFoundError({ message: "Certificate profile not found" }); @@ -315,7 +316,17 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); - return convertDalToService(profile); + const converted = convertDalToService(profile); + + if (includeMetrics) { + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return { + ...converted, + metrics + }; + } + + return converted; }; const getProfileByIdWithConfigs = async ({ @@ -401,7 +412,9 @@ export const certificateProfileServiceFactory = ({ limit = 20, search, enrollmentType, - caId + caId, + includeMetrics = false, + expiringDays = 30 }: { actor: ActorType; actorId: string; @@ -413,8 +426,10 @@ export const certificateProfileServiceFactory = ({ search?: string; enrollmentType?: EnrollmentType; caId?: string; + includeMetrics?: boolean; + expiringDays?: number; }): Promise<{ - profiles: TCertificateProfile[]; + profiles: (TCertificateProfile & { metrics?: TCertificateProfileMetrics })[]; totalCount: number; }> => { const { permission } = await permissionService.getProjectPermission({ @@ -435,7 +450,9 @@ export const certificateProfileServiceFactory = ({ limit, search, enrollmentType, - caId + caId, + includeMetrics, + expiringDays }); const totalCount = await certificateProfileDAL.countByProjectId(projectId, { @@ -444,8 +461,27 @@ export const certificateProfileServiceFactory = ({ caId }); + const convertedProfiles = profiles.map((profile) => { + const converted = convertDalToService(profile); + if (includeMetrics) { + const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; + return { + ...converted, + metrics: { + profileId: converted.id, + totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), + activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), + expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), + expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), + revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) + } + }; + } + return converted; + }); + return { - profiles: convertDalArrayToService(profiles), + profiles: convertedProfiles, totalCount }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 4eb8c8b90..2cf70878c 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -45,6 +45,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { autoRenew: boolean; autoRenewDays?: number; }; + metrics?: TCertificateProfileMetrics; }; export interface TCertificateProfileMetrics { @@ -63,6 +64,14 @@ export interface TCertificateProfileCertificate { status: string; notBefore: Date; notAfter: Date; - isRevoked: boolean; + revokedAt: Date | null | undefined; createdAt: Date; } + +export type TCertificateProfileWithRawMetrics = TCertificateProfile & { + total_certificates?: string; + active_certificates?: string; + expired_certificates?: string; + expiring_certificates?: string; + revoked_certificates?: string; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts index e31e798eb..068f58dcb 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -1,16 +1,6 @@ import { z } from "zod"; -const attributeTypeSchema = z.enum([ - "common_name", - "organization_name", - "organization_unit", - "locality", - "state", - "country", - "email", - "street_address", - "postal_code" -]); +const attributeTypeSchema = z.enum(["common_name"]); const includeTypeSchema = z.enum(["mandatory", "optional", "prohibit"]); @@ -137,14 +127,6 @@ export const deleteCertificateTemplateV2Schema = z.object({ export const certificateRequestSchema = z.object({ commonName: z.string().optional(), - organization: z.string().optional(), - organizationUnit: z.string().optional(), - locality: z.string().optional(), - state: z.string().optional(), - country: z.string().length(2).optional(), - email: z.string().email().optional(), - streetAddress: z.string().optional(), - postalCode: z.string().optional(), keyUsages: z.array(z.string()).optional(), extendedKeyUsages: z.array(z.string()).optional(), subjectAlternativeNames: z diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts index f4b93e5a2..cb3d7614f 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -60,14 +60,6 @@ describe("CertificateTemplateV2Service", () => { type: "common_name", include: "mandatory", value: ["example.com"] - }, - { - type: "organization_name", - include: "optional" - }, - { - type: "country", - include: "prohibit" } ], keyUsages: { @@ -416,7 +408,6 @@ describe("CertificateTemplateV2Service", () => { describe("validateCertificateRequest", () => { const validRequest: TCertificateRequest = { commonName: "example.com", - organization: "Example Corp", keyUsages: ["digital_signature", "key_encipherment"], extendedKeyUsages: ["server_auth"], subjectAlternativeNames: [ @@ -458,15 +449,6 @@ describe("CertificateTemplateV2Service", () => { expect(result.errors).toContain("common_name is mandatory but not provided in request"); }); - it("should detect prohibited attributes", async () => { - const invalidRequest = { ...validRequest, country: "US" }; - - const result = await service.validateCertificateRequest("template-123", invalidRequest); - - expect(result.isValid).toBe(false); - expect(result.errors).toContain("country is prohibited by template policy"); - }); - it("should validate attribute values against allowed list", async () => { const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; @@ -614,14 +596,6 @@ describe("CertificateTemplateV2Service", () => { } }); - it("should allow optional attributes when not provided", async () => { - const requestWithoutOrg = { ...validRequest, organization: undefined }; - - const result = await service.validateCertificateRequest("template-123", requestWithoutOrg); - - expect(result.isValid).toBe(true); - }); - it("should allow optional key usages and extended key usages", async () => { const requestWithOptionalUsages = { ...validRequest, @@ -805,21 +779,14 @@ describe("CertificateTemplateV2Service", () => { expect(result.isValid).toBe(true); }); - it("should handle template with all fields prohibited", async () => { + it("should handle template with SAN fields prohibited", async () => { const prohibitTemplate = { ...sampleTemplate, attributes: [ { - type: "organization_name", - include: "prohibit" as const - }, - { - type: "locality", - include: "prohibit" as const - }, - { - type: "country", - include: "prohibit" as const + type: "common_name", + include: "mandatory" as const, + value: ["example.com"] } ], keyUsages: { @@ -845,9 +812,6 @@ describe("CertificateTemplateV2Service", () => { const requestWithProhibited = { commonName: "example.com", - organization: "Test Org", - locality: "Test City", - country: "US", keyUsages: ["digital_signature"], extendedKeyUsages: ["server_auth"], subjectAlternativeNames: [ @@ -859,9 +823,6 @@ describe("CertificateTemplateV2Service", () => { const result = await service.validateCertificateRequest("template-123", requestWithProhibited); expect(result.isValid).toBe(false); - expect(result.errors).toContain("organization_name is prohibited by template policy"); - expect(result.errors).toContain("locality is prohibited by template policy"); - expect(result.errors).toContain("country is prohibited by template policy"); expect(result.errors).toContain("email SAN is prohibited by template policy"); expect(result.errors).toContain("uri SAN is prohibited by template policy"); }); @@ -874,16 +835,6 @@ describe("CertificateTemplateV2Service", () => { type: "common_name", include: "mandatory" as const, value: ["example.com", "test.com"] - }, - { - type: "organization_name", - include: "optional" as const, - value: ["Example Corp", "Test Corp"] - }, - { - type: "country", - include: "mandatory" as const, - value: ["US", "CA"] } ], subjectAlternativeNames: [ @@ -898,8 +849,6 @@ describe("CertificateTemplateV2Service", () => { const validConstrainedRequest = { commonName: "example.com", - organization: "Example Corp", - country: "US", keyUsages: ["digital_signature", "key_encipherment"], extendedKeyUsages: ["server_auth"], validity: { ttl: "30d" } @@ -910,8 +859,6 @@ describe("CertificateTemplateV2Service", () => { const invalidConstrainedRequest = { commonName: "forbidden.com", - organization: "Forbidden Corp", - country: "FR", keyUsages: ["digital_signature", "key_encipherment"], extendedKeyUsages: ["server_auth"], validity: { ttl: "30d" } @@ -920,10 +867,6 @@ describe("CertificateTemplateV2Service", () => { const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest); expect(invalidResult.isValid).toBe(false); expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); - expect(invalidResult.errors).toContain( - "organization_name value 'Forbidden Corp' does not match allowed patterns: Example Corp, Test Corp" - ); - expect(invalidResult.errors).toContain("country value 'FR' is not in allowed values list"); }); it("should validate SAN value constraints with multiple types", async () => { diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts index 2ede3053c..7aaf9028e 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -72,22 +72,6 @@ export const certificateTemplateV2ServiceFactory = ({ switch (attrType) { case "common_name": return request.commonName; - case "organization_name": - return request.organization; - case "organization_unit": - return request.organizationUnit; - case "locality": - return request.locality; - case "state": - return request.state; - case "country": - return request.country; - case "email": - return request.email; - case "street_address": - return request.streetAddress; - case "postal_code": - return request.postalCode; default: return undefined; } @@ -145,8 +129,25 @@ export const certificateTemplateV2ServiceFactory = ({ if (!requestValue) { errors.push(`${attrPolicy.type} is mandatory but not provided in request`); } else if (attrPolicy.value && attrPolicy.value.length > 0) { - if (!attrPolicy.value.includes(requestValue)) { - errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`); + // Check if the request value matches any allowed pattern + const hasWildcards = attrPolicy.value.some((val) => val.includes("*")); + const isValidValue = attrPolicy.value.some((allowedValue) => { + if (allowedValue.includes("*")) { + // Handle wildcard patterns + const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); + const regex = new RE2(`^${pattern}$`); + return regex.test(requestValue); + } + return allowedValue === requestValue; + }); + if (!isValidValue) { + if (hasWildcards) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}` + ); + } else { + errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`); + } } } } @@ -156,18 +157,24 @@ export const certificateTemplateV2ServiceFactory = ({ } if (attrPolicy.include === "optional" && requestValue && attrPolicy.value && attrPolicy.value.length > 0) { + const hasWildcards = attrPolicy.value.some((val) => val.includes("*")); const isValidValue = attrPolicy.value.some((allowedValue) => { if (allowedValue.includes("*")) { - const pattern = allowedValue.replace(/\*/g, "[^.]*"); + // Handle wildcard patterns - escape dots and replace * with .* + const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); const regex = new RE2(`^${pattern}$`); return regex.test(requestValue); } return allowedValue === requestValue; }); if (!isValidValue) { - errors.push( - `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}` - ); + if (hasWildcards) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}` + ); + } else { + errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`); + } } } }); @@ -216,9 +223,25 @@ export const certificateTemplateV2ServiceFactory = ({ if (requestSans.length === 0) { errors.push(`${sanPolicy.type} SAN is mandatory but not provided in request`); } else if (sanPolicy.value && sanPolicy.value.length > 0) { + const hasWildcards = sanPolicy.value.some((val) => val.includes("*")); requestSans.forEach((san) => { - if (!sanPolicy.value!.includes(san.value)) { - errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`); + const isValidValue = sanPolicy.value!.some((allowedValue) => { + if (allowedValue.includes("*")) { + // Handle wildcard patterns - escape dots and replace * with .* + const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); + const regex = new RE2(`^${pattern}$`); + return regex.test(san.value); + } + return allowedValue === san.value; + }); + if (!isValidValue) { + if (hasWildcards) { + errors.push( + `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}` + ); + } else { + errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`); + } } }); } @@ -229,19 +252,25 @@ export const certificateTemplateV2ServiceFactory = ({ } if (sanPolicy.include === "optional" && sanPolicy.value && sanPolicy.value.length > 0) { + const hasWildcards = sanPolicy.value.some((val) => val.includes("*")); requestSans.forEach((san) => { const isValidValue = sanPolicy.value!.some((allowedValue) => { if (allowedValue.includes("*")) { - const pattern = allowedValue.replace(/\*/g, "[^.]*"); + // Handle wildcard patterns - escape dots and replace * with .* + const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*"); const regex = new RE2(`^${pattern}$`); return regex.test(san.value); } return allowedValue === san.value; }); if (!isValidValue) { - errors.push( - `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}` - ); + if (hasWildcards) { + errors.push( + `${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}` + ); + } else { + errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`); + } } }); } diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts index 5cc947d90..cf9f52425 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -2,16 +2,7 @@ import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/ export interface TTemplateV2Policy { attributes: Array<{ - type: - | "common_name" - | "organization_name" - | "organization_unit" - | "locality" - | "state" - | "country" - | "email" - | "street_address" - | "postal_code"; + type: "common_name"; include: "mandatory" | "optional" | "prohibit"; value?: string[]; }>; @@ -97,14 +88,6 @@ export type TCertificateTemplateV2Update = Partial< export interface TCertificateRequest { commonName?: string; - organization?: string; - organizationUnit?: string; - locality?: string; - state?: string; - country?: string; - email?: string; - streetAddress?: string; - postalCode?: string; keyUsages?: string[]; extendedKeyUsages?: string[]; subjectAlternativeNames?: Array<{ 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 22eb3d8ca..dfd4acce3 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -76,7 +76,6 @@ describe("CertificateV3Service", () => { describe("issueCertificateFromProfile", () => { const mockCertificateRequest = { commonName: "test.example.com", - organization: "Test Org", keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH], validity: { ttl: "30d" }, diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 6fb16aa14..32ba7b958 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -291,14 +291,6 @@ export const certificateV3ServiceFactory = ({ const certificateRequest = { commonName: certificateOrder.commonName, - organization: certificateOrder.organization, - organizationUnit: certificateOrder.organizationUnit, - locality: certificateOrder.locality, - state: certificateOrder.state, - country: certificateOrder.country, - email: certificateOrder.email, - streetAddress: certificateOrder.streetAddress, - postalCode: certificateOrder.postalCode, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, subjectAlternativeNames: certificateOrder.identifiers.map((id) => ({ diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index b05cf2d28..bd0ec2e4d 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -6,14 +6,6 @@ export type TIssueCertificateFromProfileDTO = { profileId: string; certificateRequest: { commonName?: string; - organization?: string; - organizationUnit?: string; - locality?: string; - state?: string; - country?: string; - email?: string; - streetAddress?: string; - postalCode?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; subjectAlternativeNames?: Array<{ @@ -51,14 +43,6 @@ export type TOrderCertificateFromProfileDTO = { ttl: string; }; commonName?: string; - organization?: string; - organizationUnit?: string; - locality?: string; - state?: string; - country?: string; - email?: string; - streetAddress?: string; - postalCode?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; notBefore?: Date; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 84801b000..0969e446d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -199,7 +199,10 @@ export const SecretSyncReviewFields = () => {

{duplicateProjectId && (

- Duplicate found in project ID: {duplicateProjectId} + Duplicate found in project ID:{" "} + + {duplicateProjectId} +

)} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 36526ec18..a93e0caf9 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -2,6 +2,7 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from export { useCreateCa, useCreateCertificate, + useCreateCertificateV3, useDeleteCa, useImportCaCertificate, useRenewCa, diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index a14a0244b..a486df62e 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -9,6 +9,8 @@ import { TCreateCertificateAuthorityDTO, TCreateCertificateDTO, TCreateCertificateResponse, + TCreateCertificateV3DTO, + TCreateCertificateV3Response, TDeleteCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, @@ -148,6 +150,24 @@ export const useCreateCertificate = () => { }); }; +export const useCreateCertificateV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/issue-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; + export const useRenewCa = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 0443dd5e9..78396a904 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -171,6 +171,32 @@ export type TCreateCertificateResponse = { serialNumber: string; }; +export type TCreateCertificateV3DTO = { + projectSlug: string; + profileId: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TCreateCertificateV3Response = TCreateCertificateResponse; + export type TRenewCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts new file mode 100644 index 000000000..dc5c17efa --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -0,0 +1,14 @@ +export { + useCreateCertificateProfile, + useDeleteCertificateProfile, + useUpdateCertificateProfile +} from "./mutations"; +export { + certificateProfileKeys, + useGetCertificateProfileById, + useGetCertificateProfileBySlug, + useGetProfileCertificates, + useGetProfileMetrics, + useListCertificateProfiles +} from "./queries"; +export type * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/mutations.tsx b/frontend/src/hooks/api/certificateProfiles/mutations.tsx new file mode 100644 index 000000000..ca784ed0d --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/mutations.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { certificateProfileKeys } from "./queries"; +import { + TCertificateProfile, + TCreateCertificateProfileDTO, + TDeleteCertificateProfileDTO, + TUpdateCertificateProfileDTO +} from "./types"; + +export const useCreateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateProfile: TCertificateProfile; + }>("/api/v1/pki/certificate-profiles", data); + return response.certificateProfile; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`, data); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; + +export const useDeleteCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId }) => { + const { data: response } = await apiRequest.delete<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.removeQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx new file mode 100644 index 000000000..a1d1d259e --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -0,0 +1,154 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TCertificateProfile, + TCertificateProfileMetrics, + TCertificateProfileWithDetails, + TGetCertificateProfileByIdDTO, + TGetCertificateProfileBySlugDTO, + TGetProfileCertificatesDTO, + TGetProfileMetricsDTO, + TListCertificateProfilesDTO, + TProfileCertificate +} from "./types"; + +export const certificateProfileKeys = { + list: (params: { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + expiringDays?: number; + }) => ["certificate-profiles", "list", params], + getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], + getBySlug: (projectId: string, slug: string) => [ + "certificate-profiles", + "get-by-slug", + projectId, + slug + ], + getCertificates: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "certificates", + profileId, + params + ], + getMetrics: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "metrics", + profileId, + params + ] +}; + +export const useListCertificateProfiles = ({ + projectId, + limit = 20, + offset = 0, + search, + includeMetrics = false, + expiringDays = 7 +}: TListCertificateProfilesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.list({ + projectId, + limit, + offset, + search, + includeMetrics, + expiringDays + }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfiles: TCertificateProfile[]; + totalCount: number; + }>("/api/v1/pki/certificate-profiles", { + params: { + projectId, + limit, + offset, + search, + includeMetrics, + expiringDays + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getById(profileId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfileWithDetails; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return data.certificateProfile; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetCertificateProfileBySlug = ({ + projectId, + slug +}: TGetCertificateProfileBySlugDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getBySlug(projectId, slug), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/slug/${slug}`, { + params: { projectId } + }); + return data.certificateProfile; + }, + enabled: Boolean(projectId && slug) + }); +}; + +export const useGetProfileCertificates = ({ + profileId, + offset = 0, + limit = 20, + status, + search +}: TGetProfileCertificatesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificates: TProfileCertificate[]; + }>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, { + params: { + offset, + limit, + status, + search + } + }); + return data.certificates; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + metrics: TCertificateProfileMetrics; + }>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, { + params: { expiringDays } + }); + return data.metrics; + }, + enabled: Boolean(profileId) + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts new file mode 100644 index 000000000..05307eb9a --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -0,0 +1,130 @@ +export type TCertificateProfile = { + id: string; + projectId: string; + caId: string; + certificateTemplateId: string; + name: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfigId?: string; + apiConfigId?: string; + createdAt: string; + updatedAt: string; + metrics?: TCertificateProfileMetrics; +}; + +export type TCertificateProfileWithDetails = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + hashedPassphrase: string; + encryptedCaChain: any; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; +}; + +export type TCreateCertificateProfileDTO = { + projectId: string; + caId: string; + certificateTemplateId: string; + name: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TUpdateCertificateProfileDTO = { + profileId: string; + name?: string; + description?: string; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TDeleteCertificateProfileDTO = { + profileId: string; +}; + +export type TListCertificateProfilesDTO = { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + expiringDays?: number; +}; + +export type TGetCertificateProfileByIdDTO = { + profileId: string; +}; + +export type TGetCertificateProfileBySlugDTO = { + projectId: string; + slug: string; +}; + +export type TCertificateProfileMetrics = { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +}; + +export type TProfileCertificate = { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: Date; + notAfter: Date; + isRevoked: boolean; + createdAt: Date; +}; + +export type TGetProfileCertificatesDTO = { + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; +}; + +export type TGetProfileMetricsDTO = { + profileId: string; + expiringDays?: number; +}; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 24a7d0e5f..69c446050 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,13 +7,17 @@ import { projectKeys } from "../projects"; import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, + TCertificateTemplateV2New, TCreateCertificateTemplateDTO, TCreateCertificateTemplateV2DTO, + TCreateCertificateTemplateV2NewDTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, TDeleteCertificateTemplateV2DTO, + TDeleteCertificateTemplateV2NewDTO, TUpdateCertificateTemplateDTO, TUpdateCertificateTemplateV2DTO, + TUpdateCertificateTemplateV2NewDTO, TUpdateEstConfigDTO } from "./types"; @@ -163,3 +167,60 @@ export const useUpdateEstConfig = () => { } }); }; + +export const useCreateCertificateTemplateV2New = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateTemplate: TCertificateTemplateV2New; + }>("/api/v2/certificate-templates", data); + return response.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateTemplateV2New = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ templateId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateTemplate: TCertificateTemplateV2New; + }>(`/api/v2/certificate-templates/${templateId}`, data); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; + +export const useDeleteCertificateTemplateV2New = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ templateId }) => { + const { data: response } = await apiRequest.delete<{ + certificateTemplate: TCertificateTemplateV2New; + }>(`/api/v2/certificate-templates/${templateId}`); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.removeQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index 435345ad9..bef7aa67d 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate, TCertificateTemplateV2, + TCertificateTemplateV2New, TEstConfig, - TListCertificateTemplatesDTO + TGetCertificateTemplateV2ByIdDTO, + TListCertificateTemplatesDTO, + TListCertificateTemplatesV2DTO } from "./types"; export const certTemplateKeys = { @@ -16,7 +19,16 @@ export const certTemplateKeys = { projectId, el ], - getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] + getEstConfig: (id: string) => [{ id }, "cert-template-est-config"], + listTemplatesV2: ({ + projectId, + ...el + }: { + limit?: number; + offset?: number; + projectId: string; + }) => ["list-templates-v2", projectId, el], + getTemplateV2ById: (id: string) => ["cert-template-v2", id] }; export const useGetCertTemplate = (id: string) => { @@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => { enabled: Boolean(certificateTemplateId) }); }; + +export const useListCertificateTemplatesV2 = ({ + projectId, + limit = 20, + offset = 0 +}: TListCertificateTemplatesV2DTO) => { + return useQuery({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TCertificateTemplateV2New[]; + totalCount: number; + }>("/api/v2/certificate-templates", { + params: { + projectId, + limit, + offset + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateTemplateV2ById = ({ + templateId +}: TGetCertificateTemplateV2ByIdDTO) => { + return useQuery({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplate: TCertificateTemplateV2New; + }>(`/api/v2/certificate-templates/${templateId}`); + return data.certificateTemplate; + }, + enabled: Boolean(templateId) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 1c2a47178..f6ad1c8cb 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -121,3 +121,101 @@ export type TListCertificateTemplatesDTO = { offset?: number; projectId: string; }; + +export type TCertificateTemplateV2Policy = { + attributes: Array<{ + type: + | "common_name" + | "organization_name" + | "organization_unit" + | "locality" + | "state" + | "country" + | "email" + | "street_address" + | "postal_code"; + include: "mandatory" | "optional" | "prohibit"; + value?: string[]; + }>; + keyUsages: { + requiredUsages: { all: string[] }; + optionalUsages: { all: string[] }; + }; + extendedKeyUsages: { + requiredUsages: { all: string[] }; + optionalUsages: { all: string[] }; + }; + subjectAlternativeNames: Array<{ + type: "dns_name" | "ip_address" | "email" | "uri"; + include: "mandatory" | "optional" | "prohibit"; + value?: string[]; + }>; + validity: { + maxDuration: { value: number; unit: "days" | "months" | "years" }; + minDuration?: { value: number; unit: "days" | "months" | "years" }; + }; + signatureAlgorithm: { + allowedAlgorithms: string[]; + defaultAlgorithm: string; + }; + keyAlgorithm: { + allowedKeyTypes: string[]; + defaultKeyType: string; + }; +}; + +export type TCertificateTemplateV2New = { + id: string; + projectId: string; + name: string; + description?: string; + attributes: any; + keyUsages: any; + extendedKeyUsages: any; + subjectAlternativeNames: any; + validity: any; + signatureAlgorithm: any; + keyAlgorithm: any; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCertificateTemplateV2NewDTO = { + projectId: string; + name: string; + description?: string; + attributes: TCertificateTemplateV2Policy["attributes"]; + keyUsages: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages: TCertificateTemplateV2Policy["extendedKeyUsages"]; + subjectAlternativeNames: TCertificateTemplateV2Policy["subjectAlternativeNames"]; + validity: TCertificateTemplateV2Policy["validity"]; + signatureAlgorithm: TCertificateTemplateV2Policy["signatureAlgorithm"]; + keyAlgorithm: TCertificateTemplateV2Policy["keyAlgorithm"]; +}; + +export type TUpdateCertificateTemplateV2NewDTO = { + templateId: string; + name?: string; + description?: string; + attributes?: TCertificateTemplateV2Policy["attributes"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + subjectAlternativeNames?: TCertificateTemplateV2Policy["subjectAlternativeNames"]; + validity?: TCertificateTemplateV2Policy["validity"]; + signatureAlgorithm?: TCertificateTemplateV2Policy["signatureAlgorithm"]; + keyAlgorithm?: TCertificateTemplateV2Policy["keyAlgorithm"]; +}; + +export type TDeleteCertificateTemplateV2NewDTO = { + templateId: string; +}; + +export type TListCertificateTemplatesV2DTO = { + projectId: string; + limit?: number; + offset?: number; +}; + +export type TGetCertificateTemplateV2ByIdDTO = { + templateId: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 0384ea6cd..8647fafd5 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => { export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_3072]: "RSA 3072", [CertKeyAlgorithm.RSA_4096]: "RSA 4096", [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" @@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { export const certKeyAlgorithms = [ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], @@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [ { value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" }, { value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" } ] as const; + +export const SIGNATURE_ALGORITHMS_OPTIONS = [ + { value: "RSA-SHA256", label: "RSA-SHA256" }, + { value: "RSA-SHA384", label: "RSA-SHA384" }, + { value: "RSA-SHA512", label: "RSA-SHA512" }, + { value: "ECDSA-SHA256", label: "ECDSA-SHA256" }, + { value: "ECDSA-SHA384", label: "ECDSA-SHA384" }, + { value: "ECDSA-SHA512", label: "ECDSA-SHA512" } +] as const; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index 566da7506..ecda22afb 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -5,6 +5,7 @@ export enum CertStatus { export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1" diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ede2f8cf1..5c0fa687b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -47,6 +47,7 @@ export type SubscriptionPlan = { gateway: boolean; externalKms: boolean; pkiEst: boolean; + pkiLegacyTemplates: boolean; enforceMfa: boolean; enforceGoogleSSO: boolean; projectTemplates: boolean; diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 5a17c7592..1703a1126 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -19,6 +19,10 @@ import { motion } from "framer-motion"; import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; import { useProject, useProjectPermission } from "@app/context"; +import { + useListWorkspaceCertificateTemplates, + useListWorkspacePkiSubscribers +} from "@app/hooks/api"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; @@ -27,6 +31,16 @@ export const PkiManagerLayout = () => { const { assumedPrivilegeDetails } = useProjectPermission(); const { t } = useTranslation(); + const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + projectId: currentProject?.id || "" + }); + const templates = templatesData?.certificateTemplates || []; + + const hasExistingSubscribers = subscribers.length > 0; + const hasExistingTemplates = templates.length > 0; + const showLegacySection = hasExistingSubscribers || hasExistingTemplates; + return ( <>
@@ -48,24 +62,7 @@ export const PkiManagerLayout = () => { - {({ isActive }) => ( - -
-
- -
- Subscribers -
-
- )} - - {
- Certificate Templates + Certificate Policies
)} @@ -110,7 +107,7 @@ export const PkiManagerLayout = () => {
- Certificates Authority + Certificates Authorities )} @@ -167,6 +164,48 @@ export const PkiManagerLayout = () => { )} + {showLegacySection && ( + + {hasExistingSubscribers && ( + + {({ isActive }) => ( + +
+
+ +
+ Subscribers +
+
+ )} + + )} + {hasExistingTemplates && ( + + {({ isActive }) => ( + +
+
+ +
+ Certificate Templates +
+
+ )} + + )} +
+ )} { maxPathLength: ca.configuration.maxPathLength ? String(ca.configuration.maxPathLength) : "", - keyAlgorithm: ca.configuration.keyAlgorithm + keyAlgorithm: + ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_2048 || + ca.configuration.keyAlgorithm === CertKeyAlgorithm.RSA_4096 || + ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P256 || + ca.configuration.keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 + ? ca.configuration.keyAlgorithm + : CertKeyAlgorithm.RSA_2048 } }); } else { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx new file mode 100644 index 000000000..b17e394a3 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -0,0 +1,1018 @@ +/* eslint-disable react/no-array-index-key */ +/* eslint-disable no-nested-ternary */ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + FormLabel, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useCreateCertificateV3, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; +import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; +import { + certKeyAlgorithms, + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS, + SIGNATURE_ALGORITHMS_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage +} from "@app/hooks/api/certificates/enums"; +import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +type TriStateToggleProps = { + value: boolean | undefined; + onChange: (value: boolean | undefined) => void; + leftLabel: string; + rightLabel: string; +}; + +const TriStateToggle = ({ value, onChange, leftLabel, rightLabel }: TriStateToggleProps) => { + return ( +
+ + +
+ ); +}; + +const schema = z.object({ + profileId: z.string().min(1, "Profile is required"), + collectionId: z.string().optional(), + friendlyName: z.string(), + subjectAttributes: z + .array( + z.object({ + type: z.enum(["common_name"]), + value: z.string().min(1, "Value is required") + }) + ) + .min(1, "At least one subject attribute is required"), + altNames: z + .array( + z.object({ + type: z.enum(["dns", "ip", "email", "uri"]), + value: z.string().min(1, "Value is required") + }) + ) + .default([]), + ttl: z.string().trim().min(1, "TTL is required"), + signatureAlgorithm: z.string().optional(), + keyAlgorithm: z.string().optional(), + keyUsages: z.object({ + [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), + [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), + [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), + [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), + [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), + [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), + [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() + }), + extendedKeyUsages: z.object({ + [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), + [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), + [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), + [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() + }) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificateIssuance"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateIssuance"]>, + state?: boolean + ) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const [allowedKeyUsages, setAllowedKeyUsages] = useState([]); + const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState([]); + const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState([]); + const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState([]); + const { currentProject } = useProject(); + const { data: cert } = useGetCert( + (popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data: profilesData } = useListCertificateProfiles({ + projectId: currentProject?.id || "", + includeMetrics: false + }); + + const { data: collectionsData } = useListWorkspacePkiCollections({ + projectId: currentProject?.id || "" + }); + + const { mutateAsync: createCertificate } = useCreateCertificateV3(); + + const { + control, + handleSubmit, + reset, + watch, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + profileId: "", + friendlyName: "", + subjectAttributes: [{ type: "common_name", value: "" }], + altNames: [], + ttl: "30d", + signatureAlgorithm: "", + keyAlgorithm: "", + keyUsages: {}, + extendedKeyUsages: {} + } + }); + + const selectedProfileId = watch("profileId"); + const selectedProfile = profilesData?.certificateProfiles?.find( + (p) => p.id === selectedProfileId + ); + + const { data: templateData } = useGetCertificateTemplateV2ById({ + templateId: selectedProfile?.certificateTemplateId || "" + }); + + useEffect(() => { + if (templateData && selectedProfile) { + if (templateData.signatureAlgorithm?.defaultAlgorithm) { + let sigAlgValue = templateData.signatureAlgorithm.defaultAlgorithm; + + const sigAlgMap: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + + if (sigAlgMap[sigAlgValue]) { + sigAlgValue = sigAlgMap[sigAlgValue]; + } + + setValue("signatureAlgorithm", sigAlgValue); + } + if (templateData.keyAlgorithm?.defaultKeyType) { + let keyAlgValue = templateData.keyAlgorithm.defaultKeyType; + + const keyAlgMap: Record = { + "RSA-2048": CertKeyAlgorithm.RSA_2048, + "RSA-3072": CertKeyAlgorithm.RSA_3072, + "RSA-4096": CertKeyAlgorithm.RSA_4096, + "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256, + "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384, + [CertKeyAlgorithm.ECDSA_P256]: CertKeyAlgorithm.ECDSA_P256, + [CertKeyAlgorithm.ECDSA_P384]: CertKeyAlgorithm.ECDSA_P384 + }; + + if (keyAlgMap[keyAlgValue]) { + keyAlgValue = keyAlgMap[keyAlgValue]; + } else { + keyAlgValue = templateData.keyAlgorithm.defaultKeyType; + } + + setValue("keyAlgorithm", keyAlgValue); + } + + if (templateData.signatureAlgorithm?.allowedAlgorithms) { + const mappedSigAlgs = templateData.signatureAlgorithm.allowedAlgorithms.map( + (alg: string) => { + const sigAlgMap: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + return sigAlgMap[alg] || alg; + } + ); + setAllowedSignatureAlgorithms(mappedSigAlgs); + } + + if (templateData.keyAlgorithm?.allowedKeyTypes) { + const mappedKeyAlgs = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => { + const keyAlgMap: Record = { + "RSA-2048": CertKeyAlgorithm.RSA_2048, + "RSA-3072": CertKeyAlgorithm.RSA_3072, + "RSA-4096": CertKeyAlgorithm.RSA_4096, + "ECDSA-P256": CertKeyAlgorithm.ECDSA_P256, + "ECDSA-P384": CertKeyAlgorithm.ECDSA_P384 + }; + return keyAlgMap[alg] || alg; + }); + setAllowedKeyAlgorithms(mappedKeyAlgs); + } + + const allAllowedKeyUsages: string[] = []; + if (templateData.keyUsages?.requiredUsages?.all) { + allAllowedKeyUsages.push(...templateData.keyUsages.requiredUsages.all); + } + if (templateData.keyUsages?.optionalUsages?.all) { + allAllowedKeyUsages.push(...templateData.keyUsages.optionalUsages.all); + } + setAllowedKeyUsages([...new Set(allAllowedKeyUsages)]); + + const allAllowedExtendedKeyUsages: string[] = []; + if (templateData.extendedKeyUsages?.requiredUsages?.all) { + allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.requiredUsages.all); + } + if (templateData.extendedKeyUsages?.optionalUsages?.all) { + allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.optionalUsages.all); + } + setAllowedExtendedKeyUsages([...new Set(allAllowedExtendedKeyUsages)]); + + if (templateData.attributes && Array.isArray(templateData.attributes)) { + const subjectAttrs: Array<{ + type: "common_name"; + value: string; + }> = []; + + templateData.attributes.forEach((attr) => { + if ( + (attr.include === "mandatory" || + attr.include === "optional" || + attr.include === "prohibit") && + attr.value && + attr.value.length > 0 + ) { + attr.value.forEach((val: string) => { + subjectAttrs.push({ type: attr.type as any, value: val }); + }); + } + }); + + if (subjectAttrs.length > 0) { + setValue("subjectAttributes", subjectAttrs); + } + } + + if ( + templateData.subjectAlternativeNames && + Array.isArray(templateData.subjectAlternativeNames) + ) { + const templateSans: Array<{ type: "dns" | "ip" | "email" | "uri"; value: string }> = []; + + templateData.subjectAlternativeNames.forEach((sanPolicy) => { + if ( + (sanPolicy.include === "mandatory" || + sanPolicy.include === "optional" || + sanPolicy.include === "prohibit") && + sanPolicy.value && + sanPolicy.value.length > 0 + ) { + const typeMapping: Record = { + dns_name: "dns", + ip_address: "ip", + email: "email", + uri: "uri" + }; + + const mappedType = typeMapping[sanPolicy.type]; + if (mappedType) { + sanPolicy.value.forEach((val: string) => { + templateSans.push({ type: mappedType, value: val }); + }); + } + } + }); + + if (templateSans.length > 0) { + setValue("altNames", templateSans); + } + } + + const resetKeyUsages = { + [CertKeyUsage.DIGITAL_SIGNATURE]: false, + [CertKeyUsage.KEY_ENCIPHERMENT]: false, + [CertKeyUsage.NON_REPUDIATION]: false, + [CertKeyUsage.DATA_ENCIPHERMENT]: false, + [CertKeyUsage.KEY_AGREEMENT]: false, + [CertKeyUsage.KEY_CERT_SIGN]: false, + [CertKeyUsage.CRL_SIGN]: false, + [CertKeyUsage.ENCIPHER_ONLY]: false, + [CertKeyUsage.DECIPHER_ONLY]: false + }; + + const resetExtendedKeyUsages = { + [CertExtendedKeyUsage.CLIENT_AUTH]: false, + [CertExtendedKeyUsage.CODE_SIGNING]: false, + [CertExtendedKeyUsage.EMAIL_PROTECTION]: false, + [CertExtendedKeyUsage.OCSP_SIGNING]: false, + [CertExtendedKeyUsage.SERVER_AUTH]: false, + [CertExtendedKeyUsage.TIMESTAMPING]: false + }; + + const templateToEnumMap = { + digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, + digitalSignature: CertKeyUsage.DIGITAL_SIGNATURE, + key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, + keyEncipherment: CertKeyUsage.KEY_ENCIPHERMENT, + non_repudiation: CertKeyUsage.NON_REPUDIATION, + nonRepudiation: CertKeyUsage.NON_REPUDIATION, + data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, + dataEncipherment: CertKeyUsage.DATA_ENCIPHERMENT, + key_agreement: CertKeyUsage.KEY_AGREEMENT, + keyAgreement: CertKeyUsage.KEY_AGREEMENT, + key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, + keyCertSign: CertKeyUsage.KEY_CERT_SIGN, + crl_sign: CertKeyUsage.CRL_SIGN, + cRLSign: CertKeyUsage.CRL_SIGN, + encipher_only: CertKeyUsage.ENCIPHER_ONLY, + encipherOnly: CertKeyUsage.ENCIPHER_ONLY, + decipher_only: CertKeyUsage.DECIPHER_ONLY, + decipherOnly: CertKeyUsage.DECIPHER_ONLY, + client_auth: CertExtendedKeyUsage.CLIENT_AUTH, + clientAuth: CertExtendedKeyUsage.CLIENT_AUTH, + server_auth: CertExtendedKeyUsage.SERVER_AUTH, + serverAuth: CertExtendedKeyUsage.SERVER_AUTH, + code_signing: CertExtendedKeyUsage.CODE_SIGNING, + codeSigning: CertExtendedKeyUsage.CODE_SIGNING, + email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, + emailProtection: CertExtendedKeyUsage.EMAIL_PROTECTION, + ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, + ocspSigning: CertExtendedKeyUsage.OCSP_SIGNING, + time_stamping: CertExtendedKeyUsage.TIMESTAMPING, + timestamping: CertExtendedKeyUsage.TIMESTAMPING, + timeStamping: CertExtendedKeyUsage.TIMESTAMPING + }; + + const currentKeyUsages = { ...resetKeyUsages }; + if (templateData.keyUsages?.requiredUsages?.all) { + templateData.keyUsages.requiredUsages.all.forEach((usage: string) => { + const enumValue = (templateToEnumMap as any)[usage]; + if (enumValue && enumValue in currentKeyUsages) { + (currentKeyUsages as any)[enumValue] = true; + } + }); + } + + const currentExtendedKeyUsages = { ...resetExtendedKeyUsages }; + if (templateData.extendedKeyUsages?.requiredUsages?.all) { + templateData.extendedKeyUsages.requiredUsages.all.forEach((usage: string) => { + const enumValue = (templateToEnumMap as any)[usage]; + if (enumValue && enumValue in currentExtendedKeyUsages) { + (currentExtendedKeyUsages as any)[enumValue] = true; + } + }); + } + + setValue("keyUsages", currentKeyUsages); + setValue("extendedKeyUsages", currentExtendedKeyUsages); + } + }, [templateData, selectedProfile, setValue]); + + useEffect(() => { + if (cert) { + const subjectAttrs: Array<{ type: string; value: string }> = []; + if (cert.commonName) subjectAttrs.push({ type: "common_name", value: cert.commonName }); + + reset({ + profileId: "", + friendlyName: cert.friendlyName, + subjectAttributes: + subjectAttrs.length > 0 + ? (subjectAttrs as any) + : [{ type: "common_name" as const, value: "" }], + altNames: cert.altNames + ? cert.altNames.split(",").map((name) => { + const trimmed = name.trim(); + if (trimmed.includes("@")) return { type: "email" as const, value: trimmed }; + if (trimmed.match(/^\d+\.\d+\.\d+\.\d+$/)) + return { type: "ip" as const, value: trimmed }; + if (trimmed.startsWith("http")) return { type: "uri" as const, value: trimmed }; + return { type: "dns" as const, value: trimmed }; + }) + : [], + ttl: "", + keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), + extendedKeyUsages: Object.fromEntries( + (cert.extendedKeyUsages || []).map((name) => [name, true]) + ) + }); + } + }, [cert, reset]); + + const onFormSubmit = async ({ + profileId, + friendlyName, + collectionId, + subjectAttributes, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages + }: FormData) => { + try { + if (!currentProject?.slug) return; + + const getAttributeValue = (type: string) => { + const foundAttr = subjectAttributes.find((attr) => attr.type === type); + return foundAttr?.value || ""; + }; + + const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + profileId, + projectSlug: currentProject.slug, + pkiCollectionId: collectionId, + friendlyName, + commonName: getAttributeValue("common_name"), + altNames: altNames + .filter((san) => san.value.trim()) + .map((san) => san.value.trim()) + .join(", "), + ttl, + signatureAlgorithm: (() => { + const frontendToBackendSigAlg: Record = { + "RSA-SHA256": "SHA256-RSA", + "RSA-SHA384": "SHA384-RSA", + "RSA-SHA512": "SHA512-RSA", + "ECDSA-SHA256": "SHA256-ECDSA", + "ECDSA-SHA384": "SHA384-ECDSA", + "ECDSA-SHA512": "SHA512-ECDSA" + }; + return signatureAlgorithm + ? frontendToBackendSigAlg[signatureAlgorithm] || signatureAlgorithm + : undefined; + })(), + keyAlgorithm: (() => { + const frontendToBackendKeyAlg: Record = { + RSA_2048: "RSA-2048", + RSA_3072: "RSA-3072", + RSA_4096: "RSA-4096", + EC_prime256v1: "ECDSA-P256", + EC_secp384r1: "ECDSA-P384" + }; + return keyAlgorithm ? frontendToBackendKeyAlg[keyAlgorithm] || keyAlgorithm : undefined; + })(), + keyUsages: Object.entries(keyUsages) + .filter(([, value]) => value) + .map(([key]) => key as CertKeyUsage), + extendedKeyUsages: Object.entries(extendedKeyUsages) + .filter(([, value]) => value) + .map(([key]) => key as CertExtendedKeyUsage) + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateIssuance", isOpen); + setCertificateDetails(null); + reset(); + }} + > + + {certificateDetails && ( + + )} + {cert && ( +
+
+

Certificate Details

+

Serial Number: {cert.serialNumber}

+

Common Name: {cert.commonName}

+

Status: {cert.status}

+
+
+ )} + {!cert && !certificateDetails && ( +
+ ( + + + Certificate profiles define the policies and enrollment methods for + certificate issuance. The selected profile will enforce validation + rules and determine the CA used for signing. + + } + > + + + } + /> + + } + errorText={error?.message} + isError={Boolean(error)} + isRequired + > + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + +
+ {value.map((attr, index) => ( +
+ + { + const newValue = [...value]; + newValue[index] = { ...attr, value: e.target.value }; + onChange(newValue); + }} + placeholder="example.com" + className="flex-1" + /> + {value.length > 1 && ( + { + const newValue = value.filter((_, i) => i !== index); + onChange(newValue); + }} + > + + + )} +
+ ))} + +
+
+ )} + /> + + ( + +
+ {value.map((san, index) => ( +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={ + san.type === "dns" + ? "example.com or *.example.com" + : san.type === "ip" + ? "192.168.1.1" + : san.type === "email" + ? "admin@example.com" + : "https://example.com" + } + className="flex-1" + /> + { + const newValue = value.filter((_, i) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
+
+ )} + /> + + ( + + + + )} + /> + +
+
+ ( + + + + )} + /> +
+ +
+ ( + + + + )} + /> +
+
+ + + + Key Usages + +
+ {KEY_USAGES_OPTIONS.filter(({ value }) => { + if (allowedKeyUsages.length === 0) return true; + const templateToEnumMap = { + digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, + key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, + non_repudiation: CertKeyUsage.NON_REPUDIATION, + data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, + key_agreement: CertKeyUsage.KEY_AGREEMENT, + key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, + crl_sign: CertKeyUsage.CRL_SIGN, + encipher_only: CertKeyUsage.ENCIPHER_ONLY, + decipher_only: CertKeyUsage.DECIPHER_ONLY + }; + return allowedKeyUsages.some( + (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value + ); + }).map(({ label, value }) => ( + ( +
+ {label} + +
+ )} + /> + ))} +
+
+
+ + + Extended Key Usages + +
+ {EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => { + if (allowedExtendedKeyUsages.length === 0) return true; + const templateToEnumMap = { + client_auth: CertExtendedKeyUsage.CLIENT_AUTH, + server_auth: CertExtendedKeyUsage.SERVER_AUTH, + code_signing: CertExtendedKeyUsage.CODE_SIGNING, + email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, + ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, + time_stamping: CertExtendedKeyUsage.TIMESTAMPING, + timestamping: CertExtendedKeyUsage.TIMESTAMPING + }; + return allowedExtendedKeyUsages.some( + (allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value + ); + }).map(({ label, value }) => ( + ( +
+ {label} + +
+ )} + /> + ))} +
+
+
+
+ +
+ + +
+ + )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 696d07762..8daad5335 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -7,22 +7,28 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionCertificateActions, ProjectPermissionSub, - useProject + useProject, + useSubscription } from "@app/context"; import { useDeleteCert } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateImportModal } from "./CertificateImportModal"; +import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateModal } from "./CertificateModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; export const CertificatesSection = () => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { mutateAsync: deleteCert } = useDeleteCert(); + const useOldCertificateFlow = subscription.pkiLegacyTemplates; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateIssuance", "certificate", "certificateImport", "certificateCert", @@ -73,7 +79,9 @@ export const CertificatesSection = () => { colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("certificate")} + onClick={() => + handlePopUpOpen(useOldCertificateFlow ? "certificate" : "certificateIssuance") + } isDisabled={!isAllowed} > Issue @@ -83,7 +91,11 @@ export const CertificatesSection = () => { - + {useOldCertificateFlow ? ( + + ) : ( + + )} diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index f9680af9b..24be8596d 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -19,6 +19,8 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable"; export const PkiSubscriberSection = () => { const { currentProject } = useProject(); const projectId = currentProject.id; + + const allowNewSubscriberCreation = false; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); @@ -100,23 +102,25 @@ export const PkiSubscriberSection = () => { /> - - {(isAllowed) => ( - - )} - + {allowNewSubscriberCreation && ( + + {(isAllowed) => ( + + )} + + )} diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx index 2ffe68813..54033df40 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx @@ -58,6 +58,7 @@ export const PkiTemplateListPage = () => { const { currentProject } = useProject(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "certificateTemplate", "deleteTemplate", diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx new file mode 100644 index 000000000..b6f28bede --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; + +import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useProject } from "@app/context"; + +import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; +import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; + +enum TabSections { + CertificateTemplatesV2 = "templates-v2", + CertificateProfiles = "profiles" +} + +export const PoliciesPage = () => { + const { t } = useTranslation(); + const { currentProject } = useProject(); + const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles); + + if (!currentProject) { + return ; + } + + return ( +
+ + {t("common.head-title", { title: "Certificate Policies" })} + +
+ + + setActiveTab(value as TabSections)}> + +
+ Certificate Profiles + Certificate Templates +
+
+ + + + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx new file mode 100644 index 000000000..54f5115df --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { + TCertificateProfile, + useDeleteCertificateProfile +} from "@app/hooks/api/certificateProfiles"; + +import { CreateProfileModal } from "./CreateProfileModal"; +import { EditProfileModal } from "./EditProfileModal"; +import { ProfileList } from "./ProfileList"; + +export const CertificateProfilesTab = () => { + const { permission } = useProjectPermission(); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedProfile, setSelectedProfile] = useState(null); + + const deleteProfile = useDeleteCertificateProfile(); + + const canCreateProfile = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const handleCreateProfile = () => { + setIsCreateModalOpen(true); + }; + + const handleEditProfile = (profile: TCertificateProfile) => { + setSelectedProfile(profile); + setIsEditModalOpen(true); + }; + + const handleDeleteProfile = (profile: TCertificateProfile) => { + setSelectedProfile(profile); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedProfile) return; + + try { + await deleteProfile.mutateAsync({ + profileId: selectedProfile.id + }); + setIsDeleteModalOpen(false); + setSelectedProfile(null); + } catch (error) { + console.error("Failed to delete profile:", error); + } + }; + + return ( +
+
+
+

Certificate Profiles

+

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

+
+ + {canCreateProfile && ( + + )} +
+ + + + setIsCreateModalOpen(false)} /> + + {selectedProfile && ( + <> + { + setIsEditModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + /> + + { + setIsDeleteModalOpen(isOpen); + if (!isOpen) setSelectedProfile(null); + }} + deleteKey={selectedProfile.name} + onDeleteApproved={handleDeleteConfirm} + /> + + )} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx new file mode 100644 index 000000000..5d28e8787 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -0,0 +1,438 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; +import { useCreateCertificateProfile } from "@app/hooks/api/certificateProfiles"; +import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; + +const schema = z + .object({ + name: z.string().trim().min(1, "Profile name is required"), + slug: z.string().trim().min(1, "Profile slug is required"), + description: z.string().optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + certificateTemplateId: z.string().min(1, "Certificate Template is required"), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().min(1, "EST passphrase is required"), + caChain: z.string().min(1, "EST CA chain is required") + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +export type FormData = z.infer; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +export const CreateProfileModal = ({ isOpen, onClose }: Props) => { + const { currentProject } = useProject(); + + const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: templateData } = useListCertificateTemplatesV2({ + projectId: currentProject?.id || "", + limit: 100, + offset: 0 + }); + + const createProfile = useCreateCertificateProfile(); + + const certificateAuthorities = caData || []; + const certificateTemplates = templateData?.certificateTemplates || []; + + const { + control, + handleSubmit, + reset, + watch, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + slug: "", + description: "", + enrollmentType: "api", + certificateAuthorityId: "", + certificateTemplateId: "", + apiConfig: { + autoRenew: false, + autoRenewDays: 30 + } + } + }); + + const watchedName = watch("name"); + const watchedEnrollmentType = watch("enrollmentType"); + const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); + const watchedAutoRenew = watch("apiConfig.autoRenew"); + + useEffect(() => { + if (watchedName && !watch("slug")) { + const slug = watchedName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + setValue("slug", slug); + } + }, [watchedName, setValue, watch]); + + const onFormSubmit = async (data: FormData) => { + try { + if (!currentProject?.id) return; + + const payload: any = { + projectId: currentProject.id, + name: data.name, + slug: data.slug, + description: data.description, + enrollmentType: data.enrollmentType, + caId: data.certificateAuthorityId, + certificateTemplateId: data.certificateTemplateId + }; + + if (data.enrollmentType === "est" && data.estConfig) { + payload.estConfig = data.estConfig; + } else if (data.enrollmentType === "api" && data.apiConfig) { + payload.apiConfig = data.apiConfig; + } + await createProfile.mutateAsync(payload); + + createNotification({ + text: "Certificate profile created successfully", + type: "success" + }); + + reset(); + onClose(); + } catch (error) { + console.error("Error creating profile:", error); + createNotification({ + text: "Failed to create certificate profile", + type: "error" + }); + } + }; + + return ( + { + if (!open) { + reset(); + } + onClose(); + }} + > + +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + +