From c4dcc3dd83b0a3cd56e2dcc355068a6adb7c888a Mon Sep 17 00:00:00 2001
From: Carlos Monastyrski
Date: Thu, 9 Oct 2025 00:29:55 -0300
Subject: [PATCH 001/148] PKI revamp - backend initial changes
---
.../routes/est/certificate-est.spec.ts | 428 ++++++
.../routes/v1/certificate-profiles.spec.ts | 391 ++++++
.../v2/certificate-templates-v2.spec.ts | 292 ++++
.../e2e-test/routes/v3/certificates.spec.ts | 855 ++++++++++++
backend/src/@types/fastify.d.ts | 8 +
backend/src/@types/knex.d.ts | 32 +
.../20251007133321_pki-v3-tables.ts | 117 ++
.../src/db/schemas/api-enrollment-configs.ts | 20 +
.../src/db/schemas/certificate-profiles.ts | 27 +
.../db/schemas/certificate-templates-v2.ts | 30 +
backend/src/db/schemas/certificates.ts | 3 +-
.../src/db/schemas/est-enrollment-configs.ts | 23 +
backend/src/db/schemas/index.ts | 4 +
backend/src/db/schemas/models.ts | 4 +
.../ee/routes/est/certificate-est-router.ts | 104 +-
.../ee/services/audit-log/audit-log-types.ts | 129 ++
.../ee/services/permission/default-roles.ts | 26 +-
.../services/permission/project-permission.ts | 14 +-
backend/src/lib/api-docs/constants.ts | 1 +
backend/src/server/routes/index.ts | 50 +
.../routes/v1/certificate-profiles-router.ts | 387 +++++
backend/src/server/routes/v1/index.ts | 2 +
.../v2/certificate-templates-v2-router.ts | 265 ++++
backend/src/server/routes/v2/index.ts | 3 +
.../server/routes/v3/certificates-router.ts | 324 +++++
backend/src/server/routes/v3/index.ts | 2 +
.../certificate-authority-fns.ts | 26 +
.../internal-certificate-authority-service.ts | 23 +-
.../internal-certificate-authority-types.ts | 2 +
.../certificate-common/certificate-utils.ts | 119 ++
.../certificate-est-v3-service.ts | 294 ++++
.../certificate-profile-dal.ts | 336 +++++
.../certificate-profile-schemas.ts | 106 ++
.../certificate-profile-service.test.ts | 1129 +++++++++++++++
.../certificate-profile-service.ts | 622 +++++++++
.../certificate-profile-types.ts | 68 +
.../certificate-template-v2-dal.ts | 219 +++
.../certificate-template-v2-schemas.ts | 170 +++
.../certificate-template-v2-service.test.ts | 1243 +++++++++++++++++
.../certificate-template-v2-service.ts | 548 ++++++++
.../certificate-template-v2-types.ts | 125 ++
.../certificate-v3-service.test.ts | 372 +++++
.../certificate-v3/certificate-v3-service.ts | 395 ++++++
.../certificate-v3/certificate-v3-types.ts | 104 ++
.../api-enrollment-config-dal.ts | 103 ++
.../enrollment-config-types.ts | 29 +
.../est-enrollment-config-dal.ts | 76 +
47 files changed, 9624 insertions(+), 26 deletions(-)
create mode 100644 backend/e2e-test/routes/est/certificate-est.spec.ts
create mode 100644 backend/e2e-test/routes/v1/certificate-profiles.spec.ts
create mode 100644 backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
create mode 100644 backend/e2e-test/routes/v3/certificates.spec.ts
create mode 100644 backend/src/db/migrations/20251007133321_pki-v3-tables.ts
create mode 100644 backend/src/db/schemas/api-enrollment-configs.ts
create mode 100644 backend/src/db/schemas/certificate-profiles.ts
create mode 100644 backend/src/db/schemas/certificate-templates-v2.ts
create mode 100644 backend/src/db/schemas/est-enrollment-configs.ts
create mode 100644 backend/src/server/routes/v1/certificate-profiles-router.ts
create mode 100644 backend/src/server/routes/v2/certificate-templates-v2-router.ts
create mode 100644 backend/src/server/routes/v3/certificates-router.ts
create mode 100644 backend/src/services/certificate-common/certificate-utils.ts
create mode 100644 backend/src/services/certificate-est-v3/certificate-est-v3-service.ts
create mode 100644 backend/src/services/certificate-profile/certificate-profile-dal.ts
create mode 100644 backend/src/services/certificate-profile/certificate-profile-schemas.ts
create mode 100644 backend/src/services/certificate-profile/certificate-profile-service.test.ts
create mode 100644 backend/src/services/certificate-profile/certificate-profile-service.ts
create mode 100644 backend/src/services/certificate-profile/certificate-profile-types.ts
create mode 100644 backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
create mode 100644 backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
create mode 100644 backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
create mode 100644 backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
create mode 100644 backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
create mode 100644 backend/src/services/certificate-v3/certificate-v3-service.test.ts
create mode 100644 backend/src/services/certificate-v3/certificate-v3-service.ts
create mode 100644 backend/src/services/certificate-v3/certificate-v3-types.ts
create mode 100644 backend/src/services/enrollment-config/api-enrollment-config-dal.ts
create mode 100644 backend/src/services/enrollment-config/enrollment-config-types.ts
create mode 100644 backend/src/services/enrollment-config/est-enrollment-config-dal.ts
diff --git a/backend/e2e-test/routes/est/certificate-est.spec.ts b/backend/e2e-test/routes/est/certificate-est.spec.ts
new file mode 100644
index 000000000..58e7a1151
--- /dev/null
+++ b/backend/e2e-test/routes/est/certificate-est.spec.ts
@@ -0,0 +1,428 @@
+import { seedData1 } from "@app/db/seed-data";
+
+describe("Certificate EST Router", () => {
+ let projectId: string;
+ let certificateAuthorityId: string;
+ let templateId: string;
+ let profileId: string;
+ let estPassphrase: string;
+
+ beforeAll(async () => {
+ projectId = seedData1.project.id;
+ estPassphrase = "test-est-passphrase";
+
+ // Create a test certificate authority first
+ const caRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/pki/ca",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectSlug: seedData1.project.slug,
+ type: "root",
+ friendlyName: "Test Root CA for EST",
+ organization: "Test Org",
+ ou: "Test OU",
+ country: "US",
+ province: "CA",
+ locality: "San Francisco",
+ commonName: "Test Root CA for EST",
+ ttl: "8760h"
+ }
+ });
+ expect(caRes.statusCode).toBe(200);
+ const caPayload = JSON.parse(caRes.payload);
+ certificateAuthorityId = caPayload.certificateAuthority.id;
+
+ // Create a test certificate template v2
+ const templateRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v2/certificate-templates",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ name: "Test Template for EST",
+ policy: {
+ allowedDomains: ["est.example.com", "*.est.example.com"],
+ allowWildcards: true,
+ maxTtl: "8760h"
+ }
+ }
+ });
+ expect(templateRes.statusCode).toBe(200);
+ const templatePayload = JSON.parse(templateRes.payload);
+ templateId = templatePayload.certificateTemplate.id;
+
+ // Create a test certificate profile with EST enrollment
+ const profileRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Test EST Profile",
+ slug: "test-est-profile",
+ enrollmentMethod: "est",
+ enrollmentConfig: {
+ estConfig: {
+ passphrase: estPassphrase,
+ disableBootstrapCaValidation: false,
+ encryptedCaChain: "test-encrypted-ca-chain"
+ }
+ }
+ }
+ });
+ expect(profileRes.statusCode).toBe(200);
+ const profilePayload = JSON.parse(profileRes.payload);
+ profileId = profilePayload.certificateProfile.id;
+ });
+
+ describe("GET /:identifier/cacerts", () => {
+ test("Should get CA certificates using profileId (no authentication required)", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/est/${profileId}/cacerts`
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
+ expect(res.headers["content-transfer-encoding"]).toBe("base64");
+ expect(typeof res.payload).toBe("string");
+ });
+
+ test("Should get CA certificates using legacy templateId (backward compatibility)", async () => {
+ // First enable EST on the legacy template
+ const enableEstRes = await testServer.inject({
+ method: "PATCH",
+ url: `/api/v1/pki/templates/${templateId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ estConfig: {
+ isEnabled: true,
+ passphrase: estPassphrase
+ }
+ }
+ });
+ expect(enableEstRes.statusCode).toBe(200);
+
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/est/${templateId}/cacerts`
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
+ });
+
+ test("Should return 404 for non-existent identifier", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: "/api/est/non-existent-id/cacerts"
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("Authentication for EST endpoints", () => {
+ test("Should require authentication for simpleenroll", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).toBe(401);
+ expect(res.headers["www-authenticate"]).toBe('Basic realm="infisical"');
+ });
+
+ test("Should require authentication for simplereenroll", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simplereenroll`,
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should reject invalid credentials", async () => {
+ const invalidAuth = Buffer.from("user:wrongpassword").toString("base64");
+
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${invalidAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should accept valid credentials", async () => {
+ const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
+
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).not.toBe(401);
+ });
+ });
+
+ describe("POST /:identifier/simpleenroll", () => {
+ let validAuth: string;
+ let testCSR: string;
+
+ beforeAll(() => {
+ validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
+ // Mock CSR for testing
+ testCSR = `-----BEGIN CERTIFICATE REQUEST-----
+MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
+AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
+...mock CSR content...
+yK5ZqN8U3QR7yB+X9vG1eI+dA==
+-----END CERTIFICATE REQUEST-----`;
+ });
+
+ test("Should process enrollment request with profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: testCSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+
+ if (res.statusCode === 200) {
+ expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
+ expect(res.headers["content-transfer-encoding"]).toBe("base64");
+ }
+ });
+
+ test("Should process enrollment request with legacy templateId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${templateId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: testCSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+ });
+
+ test("Should handle PEM format CSR", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: testCSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+ });
+
+ test("Should handle base64 format CSR", async () => {
+ const base64CSR = testCSR
+ .replace(/-----BEGIN CERTIFICATE REQUEST-----/, "")
+ .replace(/-----END CERTIFICATE REQUEST-----/, "")
+ .replace(/\n/g, "");
+
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: base64CSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+ });
+
+ test("Should fail with empty CSR", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: ""
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+ });
+
+ describe("POST /:identifier/simplereenroll", () => {
+ let validAuth: string;
+ let testCSR: string;
+
+ beforeAll(() => {
+ validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
+ testCSR = `-----BEGIN CERTIFICATE REQUEST-----
+MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
+AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
+...mock CSR content...
+yK5ZqN8U3QR7yB+X9vG1eI+dA==
+-----END CERTIFICATE REQUEST-----`;
+ });
+
+ test("Should process re-enrollment request with profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simplereenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: testCSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+
+ if (res.statusCode === 200) {
+ expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
+ expect(res.headers["content-transfer-encoding"]).toBe("base64");
+ }
+ });
+
+ test("Should process re-enrollment request with legacy templateId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${templateId}/simplereenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: testCSR
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+ });
+ });
+
+ describe("EST Configuration Validation", () => {
+ test("Should fail when EST is disabled on profile", async () => {
+ // Create a profile with EST disabled
+ const disabledProfileRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Disabled EST Profile",
+ slug: "disabled-est-profile",
+ enrollmentMethod: "api" // Not EST
+ }
+ });
+ expect(disabledProfileRes.statusCode).toBe(200);
+ const disabledProfile = JSON.parse(disabledProfileRes.payload);
+
+ const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
+
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${disabledProfile.certificateProfile.id}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should handle missing authorization header gracefully", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ body: "test-csr"
+ });
+
+ expect(res.statusCode).toBe(401);
+ expect(res.headers["www-authenticate"]).toBeDefined();
+ });
+ });
+
+ describe("Content Type Handling", () => {
+ test("Should handle different content types for CSR", async () => {
+ const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
+
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/est/${profileId}/simpleenroll`,
+ headers: {
+ authorization: `Basic ${validAuth}`,
+ "content-type": "application/pkcs10"
+ },
+ body: "test-csr-content"
+ });
+
+ // Should not fail due to content type
+ expect(res.statusCode).not.toBe(415);
+ });
+ });
+
+ describe("Profile vs Template Identifier Detection", () => {
+ test("Should correctly identify UUID as profileId", async () => {
+ const isUUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ expect(isUUIDPattern.test(profileId)).toBe(true);
+
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/est/${profileId}/cacerts`
+ });
+
+ expect(res.statusCode).toBe(200);
+ });
+
+ test("Should correctly identify non-UUID as legacy templateId", async () => {
+ const isUUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ expect(isUUIDPattern.test(templateId)).toBe(false);
+
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/est/${templateId}/cacerts`
+ });
+
+ expect(res.statusCode).toBe(200);
+ });
+ });
+});
diff --git a/backend/e2e-test/routes/v1/certificate-profiles.spec.ts b/backend/e2e-test/routes/v1/certificate-profiles.spec.ts
new file mode 100644
index 000000000..23d760eb5
--- /dev/null
+++ b/backend/e2e-test/routes/v1/certificate-profiles.spec.ts
@@ -0,0 +1,391 @@
+import { seedData1 } from "@app/db/seed-data";
+
+describe("Certificate Profiles Router", () => {
+ let projectId: string;
+ let certificateAuthorityId: string;
+ let templateId: string;
+ let profileId: string;
+
+ beforeAll(async () => {
+ projectId = seedData1.project.id;
+
+ // Create a test certificate authority first
+ const caRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/pki/ca",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectSlug: seedData1.project.slug,
+ type: "root",
+ friendlyName: "Test Root CA",
+ organization: "Test Org",
+ ou: "Test OU",
+ country: "US",
+ province: "CA",
+ locality: "San Francisco",
+ commonName: "Test Root CA",
+ ttl: "8760h"
+ }
+ });
+ expect(caRes.statusCode).toBe(200);
+ const caPayload = JSON.parse(caRes.payload);
+ certificateAuthorityId = caPayload.certificateAuthority.id;
+
+ // Create a test certificate template v2
+ const templateRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v2/certificate-templates",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ name: "Test Template for Profile",
+ policy: {
+ allowedDomains: ["example.com"],
+ maxTtl: "8760h"
+ }
+ }
+ });
+ expect(templateRes.statusCode).toBe(200);
+ const templatePayload = JSON.parse(templateRes.payload);
+ templateId = templatePayload.certificateTemplate.id;
+ });
+
+ describe("POST /v1/certificate-profiles", () => {
+ test("Should create a certificate profile", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Test Profile",
+ slug: "test-profile",
+ description: "A test certificate profile",
+ enrollmentMethod: "api",
+ enrollmentConfig: {
+ apiConfig: {
+ autoRenew: true,
+ autoRenewDays: 30
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateProfile");
+ expect(payload.certificateProfile).toHaveProperty("id");
+ expect(payload.certificateProfile.name).toBe("Test Profile");
+ expect(payload.certificateProfile.slug).toBe("test-profile");
+ expect(payload.certificateProfile.projectId).toBe(projectId);
+ expect(payload.certificateProfile.certificateAuthorityId).toBe(certificateAuthorityId);
+ expect(payload.certificateProfile.certificateTemplateId).toBe(templateId);
+
+ profileId = payload.certificateProfile.id;
+ });
+
+ test("Should create profile with EST enrollment", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "EST Profile",
+ slug: "est-profile",
+ enrollmentMethod: "est",
+ enrollmentConfig: {
+ estConfig: {
+ passphrase: "test-passphrase",
+ disableBootstrapCaValidation: false,
+ encryptedCaChain: "encrypted-ca-chain-data"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificateProfile.enrollmentMethod).toBe("est");
+ });
+
+ test("Should fail to create profile with invalid project", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId: "invalid-project-id",
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Invalid Profile",
+ slug: "invalid-profile"
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should fail to create profile with duplicate slug", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Duplicate Profile",
+ slug: "test-profile" // Same slug as first profile
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+ });
+
+ describe("GET /v1/certificate-profiles", () => {
+ test("Should list certificate profiles", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles?projectId=${projectId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateProfiles");
+ expect(payload).toHaveProperty("totalCount");
+ expect(Array.isArray(payload.certificateProfiles)).toBe(true);
+ expect(payload.totalCount).toBeGreaterThan(0);
+ });
+
+ test("Should support pagination and search", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles?projectId=${projectId}&offset=0&limit=1&search=Test`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificateProfiles.length).toBeLessThanOrEqual(1);
+ });
+ });
+
+ describe("GET /v1/certificate-profiles/:id", () => {
+ test("Should get certificate profile by ID with configs", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/${profileId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateProfile");
+ expect(payload.certificateProfile.id).toBe(profileId);
+ expect(payload.certificateProfile.name).toBe("Test Profile");
+ expect(payload.certificateProfile).toHaveProperty("certificateAuthority");
+ expect(payload.certificateProfile).toHaveProperty("certificateTemplate");
+ expect(payload.certificateProfile).toHaveProperty("apiConfig");
+ });
+
+ test("Should return 404 for non-existent profile", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: "/api/v1/certificate-profiles/non-existent-id",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("GET /v1/certificate-profiles/slug/:slug", () => {
+ test("Should get certificate profile by slug", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/slug/test-profile?projectId=${projectId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateProfile");
+ expect(payload.certificateProfile.slug).toBe("test-profile");
+ });
+
+ test("Should return 404 for non-existent slug", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/slug/non-existent?projectId=${projectId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("PATCH /v1/certificate-profiles/:id", () => {
+ test("Should update certificate profile", async () => {
+ const res = await testServer.inject({
+ method: "PATCH",
+ url: `/api/v1/certificate-profiles/${profileId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ name: "Updated Test Profile",
+ description: "Updated description",
+ enrollmentConfig: {
+ apiConfig: {
+ autoRenew: false,
+ autoRenewDays: 60
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificateProfile.name).toBe("Updated Test Profile");
+ expect(payload.certificateProfile.description).toBe("Updated description");
+ });
+ });
+
+ describe("GET /v1/certificate-profiles/:id/certificates", () => {
+ test("Should list certificates for profile", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/${profileId}/certificates`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificates");
+ expect(Array.isArray(payload.certificates)).toBe(true);
+ });
+
+ test("Should support filtering and pagination", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/${profileId}/certificates?status=active&offset=0&limit=10`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificates.length).toBeLessThanOrEqual(10);
+ });
+ });
+
+ describe("GET /v1/certificate-profiles/:id/metrics", () => {
+ test("Should get profile metrics", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles/${profileId}/metrics?expiringDays=30`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("metrics");
+ expect(payload.metrics).toHaveProperty("profileId");
+ expect(payload.metrics).toHaveProperty("totalCertificates");
+ expect(payload.metrics).toHaveProperty("activeCertificates");
+ expect(payload.metrics).toHaveProperty("expiredCertificates");
+ expect(payload.metrics).toHaveProperty("expiringCertificates");
+ expect(payload.metrics).toHaveProperty("revokedCertificates");
+ expect(payload.metrics.profileId).toBe(profileId);
+ });
+ });
+
+ describe("DELETE /v1/certificate-profiles/:id", () => {
+ test("Should delete certificate profile", async () => {
+ const res = await testServer.inject({
+ method: "DELETE",
+ url: `/api/v1/certificate-profiles/${profileId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateProfile");
+ expect(payload.certificateProfile.id).toBe(profileId);
+ });
+
+ test("Should return 404 when deleting non-existent profile", async () => {
+ const res = await testServer.inject({
+ method: "DELETE",
+ url: `/api/v1/certificate-profiles/${profileId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("Authentication", () => {
+ test("Should require authentication", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles?projectId=${projectId}`
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should reject invalid token", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v1/certificate-profiles?projectId=${projectId}`,
+ headers: {
+ authorization: "Bearer invalid-token"
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+ });
+});
diff --git a/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts b/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
new file mode 100644
index 000000000..94b583829
--- /dev/null
+++ b/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
@@ -0,0 +1,292 @@
+import { seedData1 } from "@app/db/seed-data";
+
+describe("Certificate Templates V2 Router", () => {
+ let projectId: string;
+ let certificateAuthorityId: string;
+ let templateId: string;
+
+ beforeAll(async () => {
+ projectId = seedData1.project.id;
+
+ // Create a test certificate authority first
+ const caRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/pki/ca",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectSlug: seedData1.project.slug,
+ type: "root",
+ friendlyName: "Test Root CA",
+ organization: "Test Org",
+ ou: "Test OU",
+ country: "US",
+ province: "CA",
+ locality: "San Francisco",
+ commonName: "Test Root CA",
+ ttl: "8760h"
+ }
+ });
+ expect(caRes.statusCode).toBe(200);
+ const caPayload = JSON.parse(caRes.payload);
+ certificateAuthorityId = caPayload.certificateAuthority.id;
+ });
+
+ describe("POST /v2/certificate-templates", () => {
+ test("Should create a certificate template v2", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v2/certificate-templates",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ name: "Test Template V2",
+ description: "A test certificate template v2",
+ policy: {
+ allowedDomains: ["example.com", "*.example.com"],
+ allowWildcards: true,
+ allowAnyName: false,
+ allowIpSans: false,
+ allowSubdomains: true,
+ maxTtl: "8760h",
+ keyUsages: ["digital_signature", "key_agreement"],
+ extendedKeyUsages: ["server_auth", "client_auth"],
+ organizationPolicy: {
+ allowedOrganizations: ["Test Org"],
+ enforceOrganization: true
+ },
+ subjectPolicy: {
+ allowedCountries: ["US", "CA"],
+ allowedProvinces: ["CA", "NY"],
+ allowedLocalities: ["San Francisco", "New York"],
+ enforceSubject: false
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateTemplate");
+ expect(payload.certificateTemplate).toHaveProperty("id");
+ expect(payload.certificateTemplate.name).toBe("Test Template V2");
+ expect(payload.certificateTemplate.projectId).toBe(projectId);
+ expect(payload.certificateTemplate.certificateAuthorityId).toBe(certificateAuthorityId);
+
+ templateId = payload.certificateTemplate.id;
+ });
+
+ test("Should fail to create template with invalid project", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v2/certificate-templates",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId: "invalid-project-id",
+ certificateAuthorityId,
+ name: "Invalid Template",
+ policy: {}
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+ });
+
+ describe("GET /v2/certificate-templates", () => {
+ test("Should list certificate templates v2", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v2/certificate-templates?projectId=${projectId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateTemplates");
+ expect(payload).toHaveProperty("totalCount");
+ expect(Array.isArray(payload.certificateTemplates)).toBe(true);
+ expect(payload.totalCount).toBeGreaterThan(0);
+ });
+
+ test("Should support pagination", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v2/certificate-templates?projectId=${projectId}&offset=0&limit=1`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificateTemplates.length).toBeLessThanOrEqual(1);
+ });
+ });
+
+ describe("GET /v2/certificate-templates/:id", () => {
+ test("Should get certificate template v2 by ID", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v2/certificate-templates/${templateId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateTemplate");
+ expect(payload.certificateTemplate.id).toBe(templateId);
+ expect(payload.certificateTemplate.name).toBe("Test Template V2");
+ });
+
+ test("Should return 404 for non-existent template", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: "/api/v2/certificate-templates/non-existent-id",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("PATCH /v2/certificate-templates/:id", () => {
+ test("Should update certificate template v2", async () => {
+ const res = await testServer.inject({
+ method: "PATCH",
+ url: `/api/v2/certificate-templates/${templateId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ name: "Updated Test Template V2",
+ description: "Updated description",
+ policy: {
+ allowedDomains: ["updated.com"],
+ allowWildcards: false,
+ maxTtl: "4380h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.certificateTemplate.name).toBe("Updated Test Template V2");
+ expect(payload.certificateTemplate.description).toBe("Updated description");
+ });
+ });
+
+ describe("POST /v2/certificate-templates/:id/validate", () => {
+ test("Should validate certificate request against template policy", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/v2/certificate-templates/${templateId}/validate`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ request: {
+ commonName: "test.updated.com",
+ ttl: "24h",
+ keyUsages: ["digital_signature"],
+ extendedKeyUsages: ["server_auth"]
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("valid");
+ expect(typeof payload.valid).toBe("boolean");
+ if (!payload.valid) {
+ expect(payload).toHaveProperty("errors");
+ expect(Array.isArray(payload.errors)).toBe(true);
+ }
+ });
+
+ test("Should reject invalid certificate request", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: `/api/v2/certificate-templates/${templateId}/validate`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ request: {
+ commonName: "invalid.domain.com", // Not in allowed domains
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload.valid).toBe(false);
+ expect(payload.errors).toBeDefined();
+ });
+ });
+
+ describe("DELETE /v2/certificate-templates/:id", () => {
+ test("Should delete certificate template v2", async () => {
+ const res = await testServer.inject({
+ method: "DELETE",
+ url: `/api/v2/certificate-templates/${templateId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateTemplate");
+ expect(payload.certificateTemplate.id).toBe(templateId);
+ });
+
+ test("Should return 404 when deleting non-existent template", async () => {
+ const res = await testServer.inject({
+ method: "DELETE",
+ url: `/api/v2/certificate-templates/${templateId}`,
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("Authentication", () => {
+ test("Should require authentication", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v2/certificate-templates?projectId=${projectId}`
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should reject invalid token", async () => {
+ const res = await testServer.inject({
+ method: "GET",
+ url: `/api/v2/certificate-templates?projectId=${projectId}`,
+ headers: {
+ authorization: "Bearer invalid-token"
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+ });
+});
diff --git a/backend/e2e-test/routes/v3/certificates.spec.ts b/backend/e2e-test/routes/v3/certificates.spec.ts
new file mode 100644
index 000000000..b75e7c935
--- /dev/null
+++ b/backend/e2e-test/routes/v3/certificates.spec.ts
@@ -0,0 +1,855 @@
+import { seedData1 } from "@app/db/seed-data";
+
+describe("Certificates V3 Router", () => {
+ let projectId: string;
+ let certificateAuthorityId: string;
+ let templateId: string;
+ let profileId: string;
+
+ beforeAll(async () => {
+ projectId = seedData1.project.id;
+
+ // Create a test certificate authority first
+ const caRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/pki/ca",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectSlug: seedData1.project.slug,
+ type: "root",
+ friendlyName: "Test Root CA",
+ organization: "Test Org",
+ ou: "Test OU",
+ country: "US",
+ province: "CA",
+ locality: "San Francisco",
+ commonName: "Test Root CA",
+ ttl: "8760h"
+ }
+ });
+ expect(caRes.statusCode).toBe(200);
+ const caPayload = JSON.parse(caRes.payload);
+ certificateAuthorityId = caPayload.certificateAuthority.id;
+
+ // Create a test certificate template v2 with proper V2 structure
+ const templateRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v2/certificate-templates",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ name: "Test Template for V3",
+ description: "Template for testing V3 certificate endpoints",
+ attributes: [
+ {
+ type: "common_name",
+ include: "optional",
+ value: ["*.example.com", "example.com"]
+ },
+ {
+ type: "organization_name",
+ include: "optional"
+ },
+ {
+ type: "country",
+ include: "optional",
+ value: ["US", "CA"]
+ }
+ ],
+ keyUsages: {
+ requiredUsages: {
+ all: ["digital_signature"]
+ },
+ optionalUsages: {
+ all: ["key_encipherment", "key_agreement"]
+ }
+ },
+ extendedKeyUsages: {
+ requiredUsages: {
+ all: ["server_auth"]
+ },
+ optionalUsages: {
+ all: ["client_auth"]
+ }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional",
+ value: ["*.example.com", "example.com"]
+ }
+ ],
+ validity: {
+ maxDuration: {
+ value: 365,
+ unit: "days"
+ }
+ }
+ }
+ });
+ expect(templateRes.statusCode).toBe(200);
+ const templatePayload = JSON.parse(templateRes.payload);
+ templateId = templatePayload.certificateTemplate.id;
+
+ // Create a test certificate profile
+ const profileRes = await testServer.inject({
+ method: "POST",
+ url: "/api/v1/certificate-profiles",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ projectId,
+ certificateAuthorityId,
+ certificateTemplateId: templateId,
+ name: "Test Profile for V3",
+ slug: "test-profile-v3",
+ enrollmentMethod: "api",
+ enrollmentConfig: {
+ apiConfig: {
+ autoRenew: false
+ }
+ }
+ }
+ });
+ expect(profileRes.statusCode).toBe(200);
+ const profilePayload = JSON.parse(profileRes.payload);
+ profileId = profilePayload.certificateProfile.id;
+ });
+
+ describe("POST /v3/certificates/issue-certificate", () => {
+ test("Should issue a certificate using profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ organization: "Test Org",
+ organizationUnit: "Test OU",
+ locality: "San Francisco",
+ state: "CA",
+ country: "US",
+ email: "test@example.com",
+ keyUsages: ["digitalSignature"],
+ extendedKeyUsages: ["serverAuth"],
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ value: "www.example.com"
+ },
+ {
+ type: "dns_name",
+ value: "api.example.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificate");
+ expect(payload).toHaveProperty("issuingCaCertificate");
+ expect(payload).toHaveProperty("certificateChain");
+ expect(payload).toHaveProperty("privateKey");
+ expect(payload).toHaveProperty("serialNumber");
+ expect(payload).toHaveProperty("certificateId");
+
+ // Verify certificate fields
+ expect(typeof payload.certificate).toBe("string");
+ expect(payload.certificate.includes("BEGIN CERTIFICATE")).toBe(true);
+ expect(typeof payload.privateKey).toBe("string");
+ expect(payload.privateKey.includes("BEGIN PRIVATE KEY")).toBe(true);
+ });
+
+ test("Should fail with invalid profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId: "invalid-profile-id",
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ test("Should fail with domain not allowed by policy", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.notallowed.com",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should fail with invalid TTL", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "invalid-ttl"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should handle wildcard certificates", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "*.example.com",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificate");
+ });
+
+ test("Should validate certificate with profile policy", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "app.example.com",
+ keyUsages: ["digitalSignature"], // Required by template
+ extendedKeyUsages: ["serverAuth"], // Required by template
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificate");
+ expect(payload).toHaveProperty("certificateId");
+
+ // Verify the certificate was created with profile reference
+ expect(typeof payload.certificateId).toBe("string");
+ expect(payload.certificateId.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("POST /v3/certificates/sign-certificate", () => {
+ let testCSR: string;
+
+ beforeAll(() => {
+ // Mock CSR for testing - in real tests you'd generate a proper CSR
+ testCSR = `-----BEGIN CERTIFICATE REQUEST-----
+MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
+AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
+...mock CSR content...
+yK5ZqN8U3QR7yB+X9vG1eI+dA==
+-----END CERTIFICATE REQUEST-----`;
+ });
+
+ test("Should sign a CSR using profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/sign-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ csr: testCSR,
+ validity: {
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+
+ if (res.statusCode === 200) {
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificate");
+ expect(payload).toHaveProperty("issuingCaCertificate");
+ expect(payload).toHaveProperty("certificateChain");
+ expect(payload).toHaveProperty("serialNumber");
+ expect(payload).toHaveProperty("certificateId");
+
+ // Verify the certificate was created with profile reference
+ expect(typeof payload.certificateId).toBe("string");
+ expect(payload.certificateId.length).toBeGreaterThan(0);
+ }
+ });
+
+ test("Should fail with invalid CSR", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/sign-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ csr: "invalid-csr",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should fail with empty CSR", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/sign-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ csr: "",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should fail with invalid profileId for CSR signing", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/sign-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId: "invalid-profile-id",
+ csr: testCSR,
+ validity: {
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("POST /v3/certificates/order-certificate", () => {
+ test("Should create a certificate order using profileId", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "dns",
+ value: "test.example.com"
+ },
+ {
+ type: "dns",
+ value: "www.example.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ },
+ commonName: "test.example.com",
+ keyUsages: ["digitalSignature"],
+ extendedKeyUsages: ["serverAuth"],
+ organization: "Test Org"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("orderId");
+ expect(payload).toHaveProperty("status");
+ expect(payload).toHaveProperty("identifiers");
+ expect(payload).toHaveProperty("authorizations");
+ expect(payload).toHaveProperty("finalize");
+
+ // Verify order structure
+ expect(Array.isArray(payload.identifiers)).toBe(true);
+ expect(payload.identifiers.length).toBe(2);
+ expect(Array.isArray(payload.authorizations)).toBe(true);
+ expect(["pending", "processing", "valid", "invalid"]).toContain(payload.status);
+ });
+
+ test("Should fail with invalid identifiers", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [], // Empty identifiers
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should fail with disallowed domains", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "dns",
+ value: "test.notallowed.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should handle IP identifiers", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "ip",
+ value: "192.168.1.1"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect([200, 400]).toContain(res.statusCode);
+ });
+
+ test("Should validate order against template policy", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "dns",
+ value: "api.example.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ },
+ commonName: "api.example.com",
+ keyUsages: ["digitalSignature"], // Required by template
+ extendedKeyUsages: ["serverAuth"], // Required by template
+ organization: "Test Org"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("orderId");
+ expect(payload.status).toBe("valid");
+ });
+
+ test("Should fail with invalid profileId for ordering", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId: "invalid-profile-id",
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "dns",
+ value: "test.example.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+ });
+
+ describe("Validation and Error Handling", () => {
+ test("Should validate TTL format", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "0h" // Invalid TTL
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should validate country code format", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ country: "USA" // Invalid - should be 2 characters
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should validate email format", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ email: "invalid-email" // Invalid email format
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should validate key usage requirements", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ keyUsages: [], // Missing required key usages
+ extendedKeyUsages: [] // Missing required extended key usages
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ test("Should handle empty certificate request", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {} // Empty certificate request
+ }
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+ });
+
+ describe("Authentication", () => {
+ test("Should require authentication", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should reject invalid token", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: "Bearer invalid-token"
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "test.example.com",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should require authentication for CSR signing", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/sign-certificate",
+ body: {
+ profileId,
+ csr: "mock-csr",
+ validity: {
+ ttl: "24h"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+
+ test("Should require authentication for certificate ordering", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/order-certificate",
+ body: {
+ profileId,
+ certificateOrder: {
+ identifiers: [
+ {
+ type: "dns",
+ value: "test.example.com"
+ }
+ ],
+ validity: {
+ ttl: "24h"
+ }
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(401);
+ });
+ });
+
+ describe("Profile-based Certificate Management", () => {
+ test("Should create certificates with profile reference", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "profile-test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ keyUsages: ["digitalSignature"],
+ extendedKeyUsages: ["serverAuth"]
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+
+ // Verify that the certificate was created successfully
+ expect(payload).toHaveProperty("certificateId");
+ expect(typeof payload.certificateId).toBe("string");
+ expect(payload.certificateId.length).toBeGreaterThan(0);
+
+ // Verify all required fields are present
+ expect(payload).toHaveProperty("certificate");
+ expect(payload).toHaveProperty("certificateChain");
+ expect(payload).toHaveProperty("privateKey");
+ expect(payload).toHaveProperty("serialNumber");
+ expect(payload).toHaveProperty("issuingCaCertificate");
+ });
+
+ test("Should validate using template policy without template dependency", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "policy-test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ // Include required key usages as defined in template
+ keyUsages: ["digitalSignature"],
+ extendedKeyUsages: ["serverAuth"],
+ // Test optional attributes
+ organization: "Policy Test Org",
+ country: "US"
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificate");
+ expect(payload).toHaveProperty("certificateId");
+ });
+
+ test("Should handle profile permissions correctly", async () => {
+ const res = await testServer.inject({
+ method: "POST",
+ url: "/api/v3/certificates/issue-certificate",
+ headers: {
+ authorization: `Bearer ${jwtAuthToken}`
+ },
+ body: {
+ profileId,
+ certificateRequest: {
+ commonName: "permission-test.example.com",
+ validity: {
+ ttl: "24h"
+ },
+ keyUsages: ["digitalSignature"],
+ extendedKeyUsages: ["serverAuth"]
+ }
+ }
+ });
+
+ expect(res.statusCode).toBe(200);
+ const payload = JSON.parse(res.payload);
+ expect(payload).toHaveProperty("certificateId");
+ });
+ });
+});
diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts
index 088d99ee9..dcb66e212 100644
--- a/backend/src/@types/fastify.d.ts
+++ b/backend/src/@types/fastify.d.ts
@@ -63,7 +63,11 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
+import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
+import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
+import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
+import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { TCmekServiceFactory } from "@app/services/cmek/cmek-service";
import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
@@ -263,7 +267,10 @@ declare module "fastify" {
auditLog: TAuditLogServiceFactory;
auditLogStream: TAuditLogStreamServiceFactory;
certificate: TCertificateServiceFactory;
+ certificateV3: TCertificateV3ServiceFactory;
certificateTemplate: TCertificateTemplateServiceFactory;
+ certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
+ certificateProfile: TCertificateProfileServiceFactory;
sshCertificateAuthority: TSshCertificateAuthorityServiceFactory;
sshCertificateTemplate: TSshCertificateTemplateServiceFactory;
sshHost: TSshHostServiceFactory;
@@ -271,6 +278,7 @@ declare module "fastify" {
certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
+ certificateEstV3: TCertificateEstV3ServiceFactory;
pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory;
pkiSync: TPkiSyncServiceFactory;
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index c4d45ca27..318c6d67b 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -17,6 +17,9 @@ import {
TAccessApprovalRequestsReviewersInsert,
TAccessApprovalRequestsReviewersUpdate,
TAccessApprovalRequestsUpdate,
+ TApiEnrollmentConfigs,
+ TApiEnrollmentConfigsInsert,
+ TApiEnrollmentConfigsUpdate,
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
@@ -53,6 +56,9 @@ import {
TCertificateBodies,
TCertificateBodiesInsert,
TCertificateBodiesUpdate,
+ TCertificateProfiles,
+ TCertificateProfilesInsert,
+ TCertificateProfilesUpdate,
TCertificates,
TCertificateSecrets,
TCertificateSecretsInsert,
@@ -65,12 +71,18 @@ import {
TCertificateTemplates,
TCertificateTemplatesInsert,
TCertificateTemplatesUpdate,
+ TCertificateTemplatesV2,
+ TCertificateTemplatesV2Insert,
+ TCertificateTemplatesV2Update,
TDynamicSecretLeases,
TDynamicSecretLeasesInsert,
TDynamicSecretLeasesUpdate,
TDynamicSecrets,
TDynamicSecretsInsert,
TDynamicSecretsUpdate,
+ TEstEnrollmentConfigs,
+ TEstEnrollmentConfigsInsert,
+ TEstEnrollmentConfigsUpdate,
TExternalCertificateAuthorities,
TExternalCertificateAuthoritiesInsert,
TExternalCertificateAuthoritiesUpdate,
@@ -656,6 +668,26 @@ declare module "knex/types/tables" {
TCertificateTemplatesInsert,
TCertificateTemplatesUpdate
>;
+ [TableName.CertificateTemplateV2]: KnexOriginal.CompositeTableType<
+ TCertificateTemplatesV2,
+ TCertificateTemplatesV2Insert,
+ TCertificateTemplatesV2Update
+ >;
+ [TableName.CertificateProfile]: KnexOriginal.CompositeTableType<
+ TCertificateProfiles,
+ TCertificateProfilesInsert,
+ TCertificateProfilesUpdate
+ >;
+ [TableName.EstEnrollmentConfig]: KnexOriginal.CompositeTableType<
+ TEstEnrollmentConfigs,
+ TEstEnrollmentConfigsInsert,
+ TEstEnrollmentConfigsUpdate
+ >;
+ [TableName.ApiEnrollmentConfig]: KnexOriginal.CompositeTableType<
+ TApiEnrollmentConfigs,
+ TApiEnrollmentConfigsInsert,
+ TApiEnrollmentConfigsUpdate
+ >;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs,
TCertificateTemplateEstConfigsInsert,
diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts
new file mode 100644
index 000000000..d8943405c
--- /dev/null
+++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts
@@ -0,0 +1,117 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
+
+export async function up(knex: Knex): Promise {
+ if (!(await knex.schema.hasTable(TableName.CertificateTemplateV2))) {
+ await knex.schema.createTable(TableName.CertificateTemplateV2, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.string("projectId").notNullable();
+ t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
+
+ t.string("name", 64).notNullable();
+ t.string("description");
+
+ t.jsonb("attributes");
+ t.jsonb("keyUsages");
+ t.jsonb("extendedKeyUsages");
+ t.jsonb("subjectAlternativeNames");
+ t.jsonb("validity");
+ t.jsonb("signatureAlgorithm");
+ t.jsonb("keyAlgorithm");
+
+ t.timestamps(true, true, true);
+ });
+
+ await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
+ }
+
+ if (!(await knex.schema.hasTable(TableName.EstEnrollmentConfig))) {
+ await knex.schema.createTable(TableName.EstEnrollmentConfig, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+
+ t.boolean("disableBootstrapCaValidation").defaultTo(false);
+ t.text("hashedPassphrase").notNullable();
+ t.binary("encryptedCaChain").notNullable();
+
+ t.timestamps(true, true, true);
+ });
+
+ await createOnUpdateTrigger(knex, TableName.EstEnrollmentConfig);
+ }
+
+ if (!(await knex.schema.hasTable(TableName.ApiEnrollmentConfig))) {
+ await knex.schema.createTable(TableName.ApiEnrollmentConfig, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+
+ t.boolean("autoRenew").defaultTo(false);
+ t.integer("autoRenewDays");
+
+ t.timestamps(true, true, true);
+ });
+
+ await createOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig);
+ }
+
+ if (!(await knex.schema.hasTable(TableName.CertificateProfile))) {
+ await knex.schema.createTable(TableName.CertificateProfile, (t) => {
+ t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
+ t.string("projectId").notNullable();
+ t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
+
+ t.uuid("caId").notNullable();
+ t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE");
+
+ t.uuid("certificateTemplateId").notNullable();
+ t.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplateV2).onDelete("CASCADE");
+
+ t.string("name", 64).notNullable();
+ t.string("slug").notNullable();
+ t.string("description");
+ t.string("enrollmentType").notNullable().checkIn(["api", "est"]);
+
+ t.uuid("estConfigId");
+ t.foreign("estConfigId").references("id").inTable(TableName.EstEnrollmentConfig).onDelete("SET NULL");
+
+ t.uuid("apiConfigId");
+ t.foreign("apiConfigId").references("id").inTable(TableName.ApiEnrollmentConfig).onDelete("SET NULL");
+
+ t.timestamps(true, true, true);
+
+ t.unique(["slug", "projectId"], { indexName: "certificate_profiles_slug_project_id_unique" });
+ });
+
+ await createOnUpdateTrigger(knex, TableName.CertificateProfile);
+ }
+
+ if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) {
+ await knex.schema.alterTable(TableName.Certificate, (t) => {
+ t.uuid("profileId");
+ t.foreign("profileId").references("id").inTable(TableName.CertificateProfile).onDelete("SET NULL");
+ t.index("profileId", "idx_certificates_profile_id");
+ });
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) {
+ await knex.schema.alterTable(TableName.Certificate, (t) => {
+ t.dropForeign(["profileId"]);
+ t.dropIndex("profileId", "idx_certificates_profile_id");
+ t.dropColumn("profileId");
+ });
+ }
+
+ await knex.schema.dropTableIfExists(TableName.CertificateProfile);
+ await dropOnUpdateTrigger(knex, TableName.CertificateProfile);
+
+ await knex.schema.dropTableIfExists(TableName.ApiEnrollmentConfig);
+ await dropOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig);
+
+ await knex.schema.dropTableIfExists(TableName.EstEnrollmentConfig);
+ await dropOnUpdateTrigger(knex, TableName.EstEnrollmentConfig);
+
+ await knex.schema.dropTableIfExists(TableName.CertificateTemplateV2);
+ await dropOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
+}
diff --git a/backend/src/db/schemas/api-enrollment-configs.ts b/backend/src/db/schemas/api-enrollment-configs.ts
new file mode 100644
index 000000000..37fdfc163
--- /dev/null
+++ b/backend/src/db/schemas/api-enrollment-configs.ts
@@ -0,0 +1,20 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const ApiEnrollmentConfigsSchema = z.object({
+ id: z.string().uuid(),
+ autoRenew: z.boolean().default(false).nullable().optional(),
+ autoRenewDays: z.number().nullable().optional(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TApiEnrollmentConfigs = z.infer;
+export type TApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
+export type TApiEnrollmentConfigsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/certificate-profiles.ts b/backend/src/db/schemas/certificate-profiles.ts
new file mode 100644
index 000000000..888bb1996
--- /dev/null
+++ b/backend/src/db/schemas/certificate-profiles.ts
@@ -0,0 +1,27 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const CertificateProfilesSchema = z.object({
+ id: z.string().uuid(),
+ projectId: z.string(),
+ caId: z.string().uuid(),
+ certificateTemplateId: z.string().uuid(),
+ name: z.string(),
+ slug: z.string(),
+ description: z.string().nullable().optional(),
+ enrollmentType: z.string(),
+ estConfigId: z.string().uuid().nullable().optional(),
+ apiConfigId: z.string().uuid().nullable().optional(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TCertificateProfiles = z.infer;
+export type TCertificateProfilesInsert = Omit, TImmutableDBKeys>;
+export type TCertificateProfilesUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/certificate-templates-v2.ts b/backend/src/db/schemas/certificate-templates-v2.ts
new file mode 100644
index 000000000..0d91fb8da
--- /dev/null
+++ b/backend/src/db/schemas/certificate-templates-v2.ts
@@ -0,0 +1,30 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const CertificateTemplatesV2Schema = z.object({
+ id: z.string().uuid(),
+ projectId: z.string(),
+ name: z.string(),
+ description: z.string().nullable().optional(),
+ attributes: z.unknown().nullable().optional(),
+ keyUsages: z.unknown().nullable().optional(),
+ extendedKeyUsages: z.unknown().nullable().optional(),
+ subjectAlternativeNames: z.unknown().nullable().optional(),
+ validity: z.unknown().nullable().optional(),
+ signatureAlgorithm: z.unknown().nullable().optional(),
+ keyAlgorithm: z.unknown().nullable().optional(),
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TCertificateTemplatesV2 = z.infer;
+export type TCertificateTemplatesV2Insert = Omit, TImmutableDBKeys>;
+export type TCertificateTemplatesV2Update = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts
index 6bedf01ad..63122f662 100644
--- a/backend/src/db/schemas/certificates.ts
+++ b/backend/src/db/schemas/certificates.ts
@@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional(),
projectId: z.string(),
- pkiSubscriberId: z.string().uuid().nullable().optional()
+ pkiSubscriberId: z.string().uuid().nullable().optional(),
+ profileId: z.string().uuid().nullable().optional()
});
export type TCertificates = z.infer;
diff --git a/backend/src/db/schemas/est-enrollment-configs.ts b/backend/src/db/schemas/est-enrollment-configs.ts
new file mode 100644
index 000000000..8e60d58f4
--- /dev/null
+++ b/backend/src/db/schemas/est-enrollment-configs.ts
@@ -0,0 +1,23 @@
+// Code generated by automation script, DO NOT EDIT.
+// Automated by pulling database and generating zod schema
+// To update. Just run npm run generate:schema
+// Written by akhilmhdh.
+
+import { z } from "zod";
+
+import { zodBuffer } from "@app/lib/zod";
+
+import { TImmutableDBKeys } from "./models";
+
+export const EstEnrollmentConfigsSchema = z.object({
+ id: z.string().uuid(),
+ disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(),
+ hashedPassphrase: z.string(),
+ encryptedCaChain: zodBuffer,
+ createdAt: z.date(),
+ updatedAt: z.date()
+});
+
+export type TEstEnrollmentConfigs = z.infer;
+export type TEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
+export type TEstEnrollmentConfigsUpdate = Partial, TImmutableDBKeys>>;
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index f8ac885b4..fec41f3e2 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers";
export * from "./access-approval-policies-bypassers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
+export * from "./api-enrollment-configs";
export * from "./api-keys";
export * from "./app-connections";
export * from "./audit-log-streams";
@@ -15,12 +16,15 @@ export * from "./certificate-authority-certs";
export * from "./certificate-authority-crl";
export * from "./certificate-authority-secret";
export * from "./certificate-bodies";
+export * from "./certificate-profiles";
export * from "./certificate-secrets";
export * from "./certificate-template-est-configs";
export * from "./certificate-templates";
+export * from "./certificate-templates-v2";
export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";
+export * from "./est-enrollment-configs";
export * from "./external-certificate-authorities";
export * from "./external-group-org-role-mappings";
export * from "./external-kms";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index 09ecb367a..20baaa3c0 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -23,6 +23,10 @@ export enum TableName {
CertificateBody = "certificate_bodies",
CertificateSecret = "certificate_secrets",
CertificateTemplate = "certificate_templates",
+ CertificateTemplateV2 = "certificate_templates_v2",
+ CertificateProfile = "certificate_profiles",
+ EstEnrollmentConfig = "est_enrollment_configs",
+ ApiEnrollmentConfig = "api_enrollment_configs",
PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts",
PkiCollection = "pki_collections",
diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts
index 33ebe910d..be6c552ec 100644
--- a/backend/src/ee/routes/est/certificate-est-router.ts
+++ b/backend/src/ee/routes/est/certificate-est-router.ts
@@ -8,6 +8,25 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
export const registerCertificateEstRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
+ const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => {
+ try {
+ await server.services.certificateProfile.getEstConfigurationByProfile({
+ profileId: identifier
+ });
+ return "profile";
+ } catch {
+ try {
+ await server.services.certificateTemplate.getEstConfiguration({
+ isInternal: true,
+ certificateTemplateId: identifier
+ });
+ return "template";
+ } catch {
+ return null;
+ }
+ }
+ };
+
// add support for CSR bodies
server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => {
try {
@@ -59,11 +78,28 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
return;
}
- const certificateTemplateId = urlFragments.slice(-2)[0];
- const estConfig = await server.services.certificateTemplate.getEstConfiguration({
- isInternal: true,
- certificateTemplateId
- });
+ const identifier = urlFragments.slice(-2)[0];
+
+ const identifierType = await getIdentifierType(identifier);
+ if (!identifierType) {
+ res.raw.statusCode = 404;
+ res.raw.setHeader("Content-Type", "text/plain");
+ res.raw.write("Certificate template or profile not found");
+ res.raw.flushHeaders();
+ return;
+ }
+
+ let estConfig;
+ if (identifierType === "profile") {
+ estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({
+ profileId: identifier
+ });
+ } else {
+ estConfig = await server.services.certificateTemplate.getEstConfiguration({
+ isInternal: true,
+ certificateTemplateId: identifier
+ });
+ }
if (!estConfig.isEnabled) {
throw new BadRequestError({
@@ -95,14 +131,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "POST",
- url: "/:certificateTemplateId/simpleenroll",
+ url: "/:identifier/simpleenroll",
config: {
rateLimit: writeLimit
},
schema: {
body: z.string().min(1),
params: z.object({
- certificateTemplateId: z.string().min(1)
+ identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -112,9 +148,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
+ const { identifier } = req.params;
+ const identifierType = await getIdentifierType(identifier);
+
+ if (!identifierType) {
+ throw new BadRequestError({ message: "Certificate template or profile not found" });
+ }
+
+ if (identifierType === "profile") {
+ return server.services.certificateEstV3.simpleEnrollByProfile({
+ csr: req.body,
+ profileId: identifier,
+ sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
+ });
+ }
return server.services.certificateEst.simpleEnroll({
csr: req.body,
- certificateTemplateId: req.params.certificateTemplateId,
+ certificateTemplateId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
@@ -122,14 +172,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "POST",
- url: "/:certificateTemplateId/simplereenroll",
+ url: "/:identifier/simplereenroll",
config: {
rateLimit: writeLimit
},
schema: {
body: z.string().min(1),
params: z.object({
- certificateTemplateId: z.string().min(1)
+ identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -139,9 +189,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
+ const { identifier } = req.params;
+ const identifierType = await getIdentifierType(identifier);
+
+ if (!identifierType) {
+ throw new BadRequestError({ message: "Certificate template or profile not found" });
+ }
+
+ if (identifierType === "profile") {
+ return server.services.certificateEstV3.simpleReenrollByProfile({
+ csr: req.body,
+ profileId: identifier,
+ sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
+ });
+ }
return server.services.certificateEst.simpleReenroll({
csr: req.body,
- certificateTemplateId: req.params.certificateTemplateId,
+ certificateTemplateId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
@@ -149,13 +213,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "GET",
- url: "/:certificateTemplateId/cacerts",
+ url: "/:identifier/cacerts",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
- certificateTemplateId: z.string().min(1)
+ identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -165,8 +229,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
+ const { identifier } = req.params;
+ const identifierType = await getIdentifierType(identifier);
+
+ if (!identifierType) {
+ throw new BadRequestError({ message: "Certificate template or profile not found" });
+ }
+
+ if (identifierType === "profile") {
+ return server.services.certificateEstV3.getCaCertsByProfile({
+ profileId: identifier
+ });
+ }
return server.services.certificateEst.getCaCerts({
- certificateTemplateId: req.params.certificateTemplateId
+ certificateTemplateId: identifier
});
}
});
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index bc50283d4..547967bdd 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -355,6 +355,19 @@ export enum EventType {
CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config",
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
+ CREATE_CERTIFICATE_TEMPLATE_V2 = "create-certificate-template-v2",
+ UPDATE_CERTIFICATE_TEMPLATE_V2 = "update-certificate-template-v2",
+ DELETE_CERTIFICATE_TEMPLATE_V2 = "delete-certificate-template-v2",
+ GET_CERTIFICATE_TEMPLATE_V2 = "get-certificate-template-v2",
+ LIST_CERTIFICATE_TEMPLATES_V2 = "list-certificate-templates-v2",
+ CREATE_CERTIFICATE_PROFILE = "create-certificate-profile",
+ UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile",
+ DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile",
+ GET_CERTIFICATE_PROFILE = "get-certificate-profile",
+ LIST_CERTIFICATE_PROFILES = "list-certificate-profiles",
+ ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
+ SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
+ ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
@@ -2598,6 +2611,109 @@ interface GetCertificateTemplateEstConfig {
};
}
+interface CreateCertificateTemplateV2 {
+ type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2;
+ metadata: {
+ certificateTemplateId: string;
+ name: string;
+ projectId: string;
+ };
+}
+
+interface UpdateCertificateTemplateV2 {
+ type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2;
+ metadata: {
+ certificateTemplateId: string;
+ name: string;
+ };
+}
+
+interface DeleteCertificateTemplateV2 {
+ type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2;
+ metadata: {
+ certificateTemplateId: string;
+ };
+}
+
+interface GetCertificateTemplateV2 {
+ type: EventType.GET_CERTIFICATE_TEMPLATE_V2;
+ metadata: {
+ certificateTemplateId: string;
+ };
+}
+
+interface ListCertificateTemplatesV2 {
+ type: EventType.LIST_CERTIFICATE_TEMPLATES_V2;
+ metadata: {
+ projectId: string;
+ };
+}
+
+interface CreateCertificateProfile {
+ type: EventType.CREATE_CERTIFICATE_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ name: string;
+ projectId: string;
+ enrollmentType: string;
+ };
+}
+
+interface UpdateCertificateProfile {
+ type: EventType.UPDATE_CERTIFICATE_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ name: string;
+ };
+}
+
+interface DeleteCertificateProfile {
+ type: EventType.DELETE_CERTIFICATE_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ };
+}
+
+interface GetCertificateProfile {
+ type: EventType.GET_CERTIFICATE_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ };
+}
+
+interface ListCertificateProfiles {
+ type: EventType.LIST_CERTIFICATE_PROFILES;
+ metadata: {
+ projectId: string;
+ };
+}
+
+interface IssueCertificateFromProfile {
+ type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ certificateId: string;
+ commonName: string;
+ };
+}
+
+interface SignCertificateFromProfile {
+ type: EventType.SIGN_CERTIFICATE_FROM_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ certificateId: string;
+ };
+}
+
+interface OrderCertificateFromProfile {
+ type: EventType.ORDER_CERTIFICATE_FROM_PROFILE;
+ metadata: {
+ certificateProfileId: string;
+ orderId: string;
+ identifiers: string[];
+ };
+}
+
interface AttemptCreateSlackIntegration {
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
metadata: {
@@ -4058,6 +4174,19 @@ export type Event =
| CreateCertificateTemplateEstConfig
| UpdateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig
+ | CreateCertificateTemplateV2
+ | UpdateCertificateTemplateV2
+ | DeleteCertificateTemplateV2
+ | GetCertificateTemplateV2
+ | ListCertificateTemplatesV2
+ | CreateCertificateProfile
+ | UpdateCertificateProfile
+ | DeleteCertificateProfile
+ | GetCertificateProfile
+ | ListCertificateProfiles
+ | IssueCertificateFromProfile
+ | SignCertificateFromProfile
+ | OrderCertificateFromProfile
| GetAzureAdCsTemplatesEvent
| AttemptCreateSlackIntegration
| AttemptReinstallSlackIntegration
diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts
index b9cabe022..973d088f1 100644
--- a/backend/src/ee/services/permission/default-roles.ts
+++ b/backend/src/ee/services/permission/default-roles.ts
@@ -5,6 +5,7 @@ import {
ProjectPermissionAppConnectionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
+ ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionCommitsActions,
ProjectPermissionDynamicSecretActions,
@@ -72,8 +73,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionPkiTemplateActions.Delete,
- ProjectPermissionPkiTemplateActions.IssueCert,
- ProjectPermissionPkiTemplateActions.ListCerts
+ ProjectPermissionPkiTemplateActions.IssueCert, // deprecated
+ ProjectPermissionPkiTemplateActions.ListCerts // deprecated
],
ProjectPermissionSub.CertificateTemplates
);
@@ -99,6 +100,17 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.Certificates
);
+ can(
+ [
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionCertificateProfileActions.Edit,
+ ProjectPermissionCertificateProfileActions.Create,
+ ProjectPermissionCertificateProfileActions.Delete,
+ ProjectPermissionCertificateProfileActions.IssueCert
+ ],
+ ProjectPermissionSub.CertificateProfiles
+ );
+
can(
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
ProjectPermissionSub.Commits
@@ -454,7 +466,15 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.Certificates
);
- can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
+ can(
+ [
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionCertificateProfileActions.Edit,
+ ProjectPermissionCertificateProfileActions.Create,
+ ProjectPermissionCertificateProfileActions.Delete
+ ],
+ ProjectPermissionSub.CertificateProfiles
+ );
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);
diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts
index 4c7f1faac..07e17ae04 100644
--- a/backend/src/ee/services/permission/project-permission.ts
+++ b/backend/src/ee/services/permission/project-permission.ts
@@ -110,6 +110,14 @@ export enum ProjectPermissionPkiSubscriberActions {
ListCerts = "list-certs"
}
+export enum ProjectPermissionCertificateProfileActions {
+ Read = "read",
+ Create = "create",
+ Edit = "edit",
+ Delete = "delete",
+ IssueCert = "issue-cert"
+}
+
export enum ProjectPermissionSecretSyncActions {
Read = "read",
Create = "create",
@@ -245,7 +253,8 @@ export enum ProjectPermissionSub {
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
- PamSessions = "pam-sessions"
+ PamSessions = "pam-sessions",
+ CertificateProfiles = "certificate-profiles"
}
export type SecretSubjectFields = {
@@ -434,7 +443,8 @@ export type ProjectPermissionSet =
ProjectPermissionPamAccountActions,
ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields)
]
- | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
+ | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
+ | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts
index 2d0de59a2..30466fc6e 100644
--- a/backend/src/lib/api-docs/constants.ts
+++ b/backend/src/lib/api-docs/constants.ts
@@ -57,6 +57,7 @@ export enum ApiDocsTags {
PkiCertificateAuthorities = "PKI Certificate Authorities",
PkiCertificates = "PKI Certificates",
PkiCertificateTemplates = "PKI Certificate Templates",
+ PkiCertificateProfiles = "PKI Certificate Profiles",
PkiCertificateCollections = "PKI Certificate Collections",
PkiAlerting = "PKI Alerting",
PkiSubscribers = "PKI Subscribers",
diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts
index 406daa63d..278836bac 100644
--- a/backend/src/server/routes/index.ts
+++ b/backend/src/server/routes/index.ts
@@ -170,10 +170,18 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
+import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
+import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
+import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
+import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
+import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
+import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
+import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal";
+import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal";
import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
@@ -993,6 +1001,10 @@ export const registerRoutes = async (
const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db);
const certificateTemplateDAL = certificateTemplateDALFactory(db);
const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db);
+ const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db);
+ const certificateProfileDAL = certificateProfileDALFactory(db);
+ const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db);
+ const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db);
const certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db);
@@ -1077,6 +1089,19 @@ export const registerRoutes = async (
licenseService
});
+ const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({
+ certificateTemplateV2DAL,
+ permissionService
+ });
+
+ const certificateProfileService = certificateProfileServiceFactory({
+ certificateProfileDAL,
+ certificateTemplateV2DAL,
+ apiEnrollmentConfigDAL,
+ estEnrollmentConfigDAL,
+ permissionService
+ });
+
const pkiAlertService = pkiAlertServiceFactory({
pkiAlertDAL,
pkiCollectionDAL,
@@ -2043,6 +2068,27 @@ export const registerRoutes = async (
pkiSyncQueue
});
+ const certificateV3Service = certificateV3ServiceFactory({
+ certificateDAL,
+ certificateAuthorityDAL,
+ certificateProfileDAL,
+ certificateTemplateV2Service,
+ internalCaService: internalCertificateAuthorityService,
+ permissionService
+ });
+
+ const certificateEstV3Service = certificateEstV3ServiceFactory({
+ internalCertificateAuthorityService,
+ certificateTemplateDAL,
+ certificateAuthorityDAL,
+ certificateAuthorityCertDAL,
+ projectDAL,
+ kmsService,
+ licenseService,
+ certificateProfileDAL,
+ estEnrollmentConfigDAL
+ });
+
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
certificateAuthorityDAL,
@@ -2243,6 +2289,8 @@ export const registerRoutes = async (
auditLog: auditLogService,
auditLogStream: auditLogStreamService,
certificate: certificateService,
+ certificateV3: certificateV3Service,
+ certificateEstV3: certificateEstV3Service,
sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService,
sshHost: sshHostService,
@@ -2250,6 +2298,8 @@ export const registerRoutes = async (
certificateAuthority: certificateAuthorityService,
internalCertificateAuthority: internalCertificateAuthorityService,
certificateTemplate: certificateTemplateService,
+ certificateTemplateV2: certificateTemplateV2Service,
+ certificateProfile: certificateProfileService,
certificateAuthorityCrl: certificateAuthorityCrlService,
certificateEst: certificateEstService,
pit: pitService,
diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts
new file mode 100644
index 000000000..5afbce96f
--- /dev/null
+++ b/backend/src/server/routes/v1/certificate-profiles-router.ts
@@ -0,0 +1,387 @@
+import { z } from "zod";
+
+import { CertificateProfilesSchema } from "@app/db/schemas";
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { ApiDocsTags } from "@app/lib/api-docs";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+import {
+ createCertificateProfileSchema,
+ deleteCertificateProfileSchema,
+ getCertificateProfileByIdSchema,
+ listCertificateProfilesSchema,
+ updateCertificateProfileSchema
+} from "@app/services/certificate-profile/certificate-profile-schemas";
+
+export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "POST",
+ url: "/",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ body: createCertificateProfileSchema,
+ response: {
+ 200: z.object({
+ certificateProfile: CertificateProfilesSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateProfile = await server.services.certificateProfile.createProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ projectId: req.body.projectId,
+ data: req.body
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: req.body.projectId,
+ event: {
+ type: EventType.CREATE_CERTIFICATE_PROFILE,
+ metadata: {
+ certificateProfileId: certificateProfile.id,
+ name: certificateProfile.name,
+ projectId: certificateProfile.projectId,
+ enrollmentType: certificateProfile.enrollmentType
+ }
+ }
+ });
+
+ return { certificateProfile };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ querystring: listCertificateProfilesSchema,
+ response: {
+ 200: z.object({
+ certificateProfiles: CertificateProfilesSchema.array(),
+ totalCount: z.number()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ ...req.query
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: req.query.projectId,
+ event: {
+ type: EventType.LIST_CERTIFICATE_PROFILES,
+ metadata: {
+ projectId: req.query.projectId
+ }
+ }
+ });
+
+ return { certificateProfiles: profiles, totalCount };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:id",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ params: getCertificateProfileByIdSchema,
+ response: {
+ 200: z.object({
+ certificateProfile: CertificateProfilesSchema.extend({
+ certificateAuthority: z
+ .object({
+ id: z.string(),
+ projectId: z.string(),
+ status: z.string(),
+ name: z.string()
+ })
+ .optional(),
+ certificateTemplate: z
+ .object({
+ id: z.string(),
+ projectId: z.string(),
+ name: z.string(),
+ description: z.string().optional()
+ })
+ .optional(),
+ estConfig: z
+ .object({
+ id: z.string(),
+ disableBootstrapCaValidation: z.boolean(),
+ hashedPassphrase: z.string(),
+ encryptedCaChain: z.any()
+ })
+ .optional(),
+ apiConfig: z
+ .object({
+ id: z.string(),
+ autoRenew: z.boolean(),
+ autoRenewDays: z.number().optional()
+ })
+ .optional()
+ })
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.params.id
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateProfile.projectId,
+ event: {
+ type: EventType.GET_CERTIFICATE_PROFILE,
+ metadata: {
+ certificateProfileId: certificateProfile.id
+ }
+ }
+ });
+
+ return { certificateProfile };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/slug/:slug",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ params: z.object({
+ slug: z.string().min(1)
+ }),
+ querystring: z.object({
+ projectId: z.string().min(1)
+ }),
+ response: {
+ 200: z.object({
+ certificateProfile: CertificateProfilesSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateProfile = await server.services.certificateProfile.getProfileBySlug({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ projectId: req.query.projectId,
+ slug: req.params.slug
+ });
+
+ return { certificateProfile };
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/:id",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ params: getCertificateProfileByIdSchema,
+ body: updateCertificateProfileSchema,
+ response: {
+ 200: z.object({
+ certificateProfile: CertificateProfilesSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateProfile = await server.services.certificateProfile.updateProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.params.id,
+ data: req.body
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateProfile.projectId,
+ event: {
+ type: EventType.UPDATE_CERTIFICATE_PROFILE,
+ metadata: {
+ certificateProfileId: certificateProfile.id,
+ name: certificateProfile.name
+ }
+ }
+ });
+
+ return { certificateProfile };
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/:id",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ params: deleteCertificateProfileSchema,
+ response: {
+ 200: z.object({
+ certificateProfile: CertificateProfilesSchema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateProfile = await server.services.certificateProfile.deleteProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.params.id
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateProfile.projectId,
+ event: {
+ type: EventType.DELETE_CERTIFICATE_PROFILE,
+ metadata: {
+ certificateProfileId: certificateProfile.id
+ }
+ }
+ });
+
+ return { certificateProfile };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:id/certificates",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateProfiles],
+ params: getCertificateProfileByIdSchema,
+ querystring: z.object({
+ offset: z.number().min(0).default(0),
+ limit: z.number().min(1).max(100).default(20),
+ status: z.enum(["active", "expired", "revoked"]).optional(),
+ search: z.string().optional()
+ }),
+ response: {
+ 200: z.object({
+ certificates: z.array(
+ z.object({
+ id: z.string(),
+ serialNumber: z.string(),
+ cn: z.string(),
+ status: z.string(),
+ notBefore: z.date(),
+ notAfter: z.date(),
+ isRevoked: z.boolean(),
+ createdAt: z.date()
+ })
+ )
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificates = await server.services.certificateProfile.getProfileCertificates({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.params.id,
+ ...req.query
+ });
+
+ return { certificates };
+ }
+ });
+
+ 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/v1/index.ts b/backend/src/server/routes/v1/index.ts
index 89865b1a1..03605cc3c 100644
--- a/backend/src/server/routes/v1/index.ts
+++ b/backend/src/server/routes/v1/index.ts
@@ -12,6 +12,7 @@ import { registerProjectBotRouter } from "./bot-router";
import { registerCaRouter } from "./certificate-authority-router";
import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers";
import { registerCertRouter } from "./certificate-router";
+import { registerCertificateProfilesRouter } from "./certificate-profiles-router";
import { registerCertificateTemplateRouter } from "./certificate-template-router";
import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";
@@ -146,6 +147,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
);
await pkiRouter.register(registerCertRouter, { prefix: "/certificates" });
await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" });
+ await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" });
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" });
await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" });
diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts
new file mode 100644
index 000000000..a33a4b91c
--- /dev/null
+++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts
@@ -0,0 +1,265 @@
+import { z } from "zod";
+
+import { CertificateTemplatesV2Schema } from "@app/db/schemas";
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { ApiDocsTags } from "@app/lib/api-docs";
+import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+import {
+ certificateRequestSchema,
+ createCertificateTemplateV2Schema,
+ deleteCertificateTemplateV2Schema,
+ getCertificateTemplateV2ByIdSchema,
+ listCertificateTemplatesV2Schema,
+ updateCertificateTemplateV2Schema
+} from "@app/services/certificate-template-v2/certificate-template-v2-schemas";
+
+export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "POST",
+ url: "/",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ body: createCertificateTemplateV2Schema,
+ response: {
+ 200: z.object({
+ certificateTemplate: CertificateTemplatesV2Schema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { projectId, ...data } = req.body;
+ const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod!,
+ actorOrgId: req.permission.orgId,
+ projectId,
+ data
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId,
+ event: {
+ type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2,
+ metadata: {
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.name,
+ projectId: certificateTemplate.projectId
+ }
+ }
+ });
+
+ return { certificateTemplate };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ querystring: listCertificateTemplatesV2Schema,
+ response: {
+ 200: z.object({
+ certificateTemplates: CertificateTemplatesV2Schema.array(),
+ totalCount: z.number()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod!,
+ actorOrgId: req.permission.orgId,
+ ...req.query
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: req.query.projectId,
+ event: {
+ type: EventType.LIST_CERTIFICATE_TEMPLATES_V2,
+ metadata: {
+ projectId: req.query.projectId
+ }
+ }
+ });
+
+ return { certificateTemplates: templates, totalCount };
+ }
+ });
+
+ server.route({
+ method: "GET",
+ url: "/:id",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ params: getCertificateTemplateV2ByIdSchema,
+ response: {
+ 200: z.object({
+ certificateTemplate: CertificateTemplatesV2Schema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod!,
+ actorOrgId: req.permission.orgId,
+ templateId: req.params.id
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateTemplate.projectId,
+ event: {
+ type: EventType.GET_CERTIFICATE_TEMPLATE_V2,
+ metadata: {
+ certificateTemplateId: certificateTemplate.id
+ }
+ }
+ });
+
+ return { certificateTemplate };
+ }
+ });
+
+ server.route({
+ method: "PATCH",
+ url: "/:id",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ params: getCertificateTemplateV2ByIdSchema,
+ body: updateCertificateTemplateV2Schema,
+ response: {
+ 200: z.object({
+ certificateTemplate: CertificateTemplatesV2Schema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod!,
+ actorOrgId: req.permission.orgId,
+ templateId: req.params.id,
+ data: req.body
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateTemplate.projectId,
+ event: {
+ type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2,
+ metadata: {
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.name
+ }
+ }
+ });
+
+ return { certificateTemplate };
+ }
+ });
+
+ server.route({
+ method: "DELETE",
+ url: "/:id",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ params: deleteCertificateTemplateV2Schema,
+ response: {
+ 200: z.object({
+ certificateTemplate: CertificateTemplatesV2Schema
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod!,
+ actorOrgId: req.permission.orgId,
+ templateId: req.params.id
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: certificateTemplate.projectId,
+ event: {
+ type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2,
+ metadata: {
+ certificateTemplateId: certificateTemplate.id
+ }
+ }
+ });
+
+ return { certificateTemplate };
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/:id/validate",
+ config: {
+ rateLimit: readLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificateTemplates],
+ params: getCertificateTemplateV2ByIdSchema,
+ body: z.object({
+ request: certificateRequestSchema
+ }),
+ response: {
+ 200: z.object({
+ valid: z.boolean(),
+ errors: z.array(z.string()).optional()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const result = await server.services.certificateTemplateV2.validateCertificateRequest(
+ req.params.id,
+ req.body.request
+ );
+
+ return {
+ valid: result.isValid,
+ errors: result.errors.length > 0 ? result.errors : undefined
+ };
+ }
+ });
+};
diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts
index aade29bb7..db4ebb176 100644
--- a/backend/src/server/routes/v2/index.ts
+++ b/backend/src/server/routes/v2/index.ts
@@ -1,4 +1,5 @@
import { registerCaRouter } from "./certificate-authority-router";
+import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router";
import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router";
import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";
@@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
await server.register(registerPasswordRouter, { prefix: "/password" });
+ await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" });
+
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts
new file mode 100644
index 000000000..eb85002ab
--- /dev/null
+++ b/backend/src/server/routes/v3/certificates-router.ts
@@ -0,0 +1,324 @@
+import { z } from "zod";
+
+import { EventType } from "@app/ee/services/audit-log/audit-log-types";
+import { ApiDocsTags } from "@app/lib/api-docs";
+import { ms } from "@app/lib/ms";
+import { writeLimit } from "@app/server/config/rateLimiter";
+import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
+import { AuthMode } from "@app/services/auth/auth-type";
+import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
+import {
+ validateAltNamesField,
+ validateAndMapAltNameType,
+ validateCaDateField
+} from "@app/services/certificate-authority/certificate-authority-validators";
+import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
+
+export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
+ server.route({
+ method: "POST",
+ url: "/issue-certificate",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificates],
+ body: z.object({
+ profileId: z.string().uuid(),
+ commonName: validateTemplateRegexField,
+ ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
+ keyUsages: z.nativeEnum(CertKeyUsage).array().optional(),
+ extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(),
+ notBefore: validateCaDateField.optional(),
+ notAfter: validateCaDateField.optional(),
+ altNames: validateAltNamesField.optional(),
+ 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()
+ }),
+ response: {
+ 200: z.object({
+ certificate: z.string().trim(),
+ issuingCaCertificate: z.string().trim(),
+ certificateChain: z.string().trim(),
+ privateKey: z.string().trim().optional(),
+ serialNumber: z.string().trim(),
+ certificateId: z.string()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const data = await server.services.certificateV3.issueCertificateFromProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId,
+ certificateRequest: {
+ 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
+ ? req.body.altNames
+ .split(", ")
+ .map((name) => name.trim())
+ .map((name) => {
+ const mappedType = validateAndMapAltNameType(name);
+ if (!mappedType) return null;
+ const typeMapping = {
+ dns: "dns_name",
+ ip: "ip_address",
+ email: "email",
+ url: "uri"
+ } as const;
+ return {
+ type: typeMapping[mappedType.type] as "dns_name" | "ip_address" | "email" | "uri",
+ value: mappedType.value
+ };
+ })
+ .filter((item): item is NonNullable => item !== null)
+ : undefined,
+ validity: {
+ ttl: req.body.ttl
+ },
+ notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
+ notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
+ signatureAlgorithm: req.body.signatureAlgorithm,
+ keyAlgorithm: req.body.keyAlgorithm
+ }
+ });
+
+ const profile = await server.services.certificateProfile.getProfileById({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: profile.projectId,
+ event: {
+ type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
+ metadata: {
+ certificateProfileId: req.body.profileId,
+ certificateId: data.certificateId,
+ commonName: req.body.commonName || ""
+ }
+ }
+ });
+
+ return data;
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/sign-certificate",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificates],
+ body: z.object({
+ profileId: z.string().uuid(),
+ csr: z.string().trim().min(1).max(4096),
+ ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
+ notBefore: validateCaDateField.optional(),
+ notAfter: validateCaDateField.optional()
+ }),
+ response: {
+ 200: z.object({
+ certificate: z.string().trim(),
+ issuingCaCertificate: z.string().trim(),
+ certificateChain: z.string().trim(),
+ serialNumber: z.string().trim(),
+ certificateId: z.string()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const data = await server.services.certificateV3.signCertificateFromProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId,
+ csr: req.body.csr,
+ validity: {
+ ttl: req.body.ttl
+ },
+ notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
+ notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
+ });
+
+ const profile = await server.services.certificateProfile.getProfileById({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: profile.projectId,
+ event: {
+ type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
+ metadata: {
+ certificateProfileId: req.body.profileId,
+ certificateId: data.certificateId
+ }
+ }
+ });
+
+ return data;
+ }
+ });
+
+ server.route({
+ method: "POST",
+ url: "/order-certificate",
+ config: {
+ rateLimit: writeLimit
+ },
+ schema: {
+ hide: false,
+ tags: [ApiDocsTags.PkiCertificates],
+ body: z.object({
+ profileId: z.string().uuid(),
+ identifiers: z
+ .array(
+ z.object({
+ type: z.enum(["dns", "ip"]),
+ value: z.string()
+ })
+ )
+ .min(1),
+ ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
+ keyUsages: z.nativeEnum(CertKeyUsage).array().optional(),
+ extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(),
+ notBefore: validateCaDateField.optional(),
+ notAfter: validateCaDateField.optional(),
+ commonName: validateTemplateRegexField.optional(),
+ 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()
+ }),
+ response: {
+ 200: z.object({
+ orderId: z.string(),
+ status: z.enum(["pending", "processing", "valid", "invalid"]),
+ identifiers: z.array(
+ z.object({
+ type: z.enum(["dns", "ip"]),
+ value: z.string(),
+ status: z.enum(["pending", "processing", "valid", "invalid"])
+ })
+ ),
+ authorizations: z.array(
+ z.object({
+ identifier: z.object({
+ type: z.enum(["dns", "ip"]),
+ value: z.string()
+ }),
+ status: z.enum(["pending", "processing", "valid", "invalid"]),
+ expires: z.string().optional(),
+ challenges: z.array(
+ z.object({
+ type: z.string(),
+ status: z.enum(["pending", "processing", "valid", "invalid"]),
+ url: z.string(),
+ token: z.string()
+ })
+ )
+ })
+ ),
+ finalize: z.string(),
+ certificate: z.string().optional()
+ })
+ }
+ },
+ onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
+ handler: async (req) => {
+ const data = await server.services.certificateV3.orderCertificateFromProfile({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId,
+ certificateOrder: {
+ identifiers: req.body.identifiers,
+ validity: {
+ 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,
+ notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
+ signatureAlgorithm: req.body.signatureAlgorithm,
+ keyAlgorithm: req.body.keyAlgorithm
+ }
+ });
+
+ const profile = await server.services.certificateProfile.getProfileById({
+ actor: req.permission.type,
+ actorId: req.permission.id,
+ actorAuthMethod: req.permission.authMethod,
+ actorOrgId: req.permission.orgId,
+ profileId: req.body.profileId
+ });
+
+ await server.services.auditLog.createAuditLog({
+ ...req.auditLogInfo,
+ projectId: profile.projectId,
+ event: {
+ type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
+ metadata: {
+ certificateProfileId: req.body.profileId,
+ orderId: data.orderId,
+ identifiers: req.body.identifiers.map((id) => `${id.type}:${id.value}`)
+ }
+ }
+ });
+
+ return data;
+ }
+ });
+};
diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts
index d7fa94d6b..47c3c2cb8 100644
--- a/backend/src/server/routes/v3/index.ts
+++ b/backend/src/server/routes/v3/index.ts
@@ -1,3 +1,4 @@
+import { registerCertificatesRouter } from "./certificates-router";
import { registerDeprecatedSecretRouter } from "./deprecated-secret-router";
import { registerExternalMigrationRouter } from "./external-migration-router";
import { registerLoginRouter } from "./login-router";
@@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" });
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
+ await server.register(registerCertificatesRouter, { prefix: "/certificates" });
};
diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts
index 9991e462e..4b9854abe 100644
--- a/backend/src/services/certificate-authority/certificate-authority-fns.ts
+++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts
@@ -99,6 +99,32 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => {
}
};
+export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm) => {
+ // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc.
+ const [keyType, hashType] = signatureAlgorithm.split("-");
+
+ switch (keyType) {
+ case "RSA":
+ return {
+ name: "RSASSA-PKCS1-v1_5",
+ hash: hashType || "SHA-256",
+ publicExponent: new Uint8Array([1, 0, 1]),
+ modulusLength: keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : 2048
+ };
+ case "ECDSA":
+ // eslint-disable-next-line no-case-declarations
+ const namedCurve = keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 ? "P-384" : "P-256";
+ return {
+ name: "ECDSA",
+ namedCurve,
+ hash: hashType || (namedCurve === "P-384" ? "SHA-384" : "SHA-256")
+ };
+ default:
+ // Fallback to key algorithm default
+ return keyAlgorithmToAlgCfg(keyAlgorithm);
+ }
+};
+
/**
* Return the public and private key of CA with id [caId]
* Note: credentials are returned as crypto.webcrypto.CryptoKey
diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts
index ab89ea996..1b09cf0fd 100644
--- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts
+++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts
@@ -48,7 +48,8 @@ import {
getCaCertChains,
getCaCredentials,
keyAlgorithmToAlgCfg,
- parseDistinguishedName
+ parseDistinguishedName,
+ signatureAlgorithmToAlgCfg
} from "../certificate-authority-fns";
import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
@@ -1174,7 +1175,9 @@ export const internalCertificateAuthorityServiceFactory = ({
actor,
actorOrgId,
keyUsages,
- extendedKeyUsages
+ extendedKeyUsages,
+ signatureAlgorithm,
+ keyAlgorithm
}: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined;
@@ -1277,13 +1280,21 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
- const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
- const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
+ // Use provided keyAlgorithm if available, otherwise fall back to CA's algorithm
+ const effectiveKeyAlgorithm =
+ (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
+ const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm);
+ const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]);
+
+ // Determine signing algorithm for certificate signing
+ const signingAlg = signatureAlgorithm
+ ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm)
+ : keyGenAlg;
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${commonName}`,
keys: leafKeys,
- signingAlgorithm: alg,
+ signingAlgorithm: keyGenAlg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
@@ -1405,7 +1416,7 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate,
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
- signingAlgorithm: alg,
+ signingAlgorithm: signingAlg,
extensions
});
diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts
index fadd7b88d..06b25e261 100644
--- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts
+++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts
@@ -131,6 +131,8 @@ export type TIssueCertFromCaDTO = {
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
+ signatureAlgorithm?: string;
+ keyAlgorithm?: string;
} & Omit;
export type TSignCertFromCaDTO =
diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts
new file mode 100644
index 000000000..0287450d0
--- /dev/null
+++ b/backend/src/services/certificate-common/certificate-utils.ts
@@ -0,0 +1,119 @@
+interface CertificateRequestInput {
+ keyUsages?: string[];
+ extendedKeyUsages?: string[];
+ [key: string]: unknown;
+}
+
+export const mapEnumsForValidation = (request: T): T => {
+ const keyUsageMapping: Record = {
+ digitalSignature: "digital_signature",
+ keyEncipherment: "key_encipherment",
+ nonRepudiation: "non_repudiation",
+ dataEncipherment: "data_encipherment",
+ keyAgreement: "key_agreement",
+ keyCertSign: "key_cert_sign",
+ crlSign: "crl_sign",
+ encipherOnly: "encipher_only",
+ decipherOnly: "decipher_only"
+ };
+
+ const extendedKeyUsageMapping: Record = {
+ serverAuth: "server_auth",
+ clientAuth: "client_auth",
+ codeSigning: "code_signing",
+ emailProtection: "email_protection",
+ timeStamping: "time_stamping",
+ ocspSigning: "ocsp_signing"
+ };
+
+ return {
+ ...request,
+ keyUsages: request.keyUsages?.map((usage: string) => keyUsageMapping[usage] || usage),
+ extendedKeyUsages: request.extendedKeyUsages?.map((usage: string) => extendedKeyUsageMapping[usage] || usage)
+ } as T;
+};
+
+export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => {
+ if (!date) return undefined;
+ return date instanceof Date ? date.toISOString() : date;
+};
+
+export const bufferToString = (data: Buffer | string): string => {
+ return String(data);
+};
+
+export const buildCertificateSubjectFromTemplate = (
+ request: Record,
+ templateAttributes?: Array<{
+ type: string;
+ include: "mandatory" | "optional" | "prohibit";
+ value?: string[];
+ }>
+): Record => {
+ const subject: Record = {};
+ const attributeMap: Record = {
+ common_name: "commonName",
+ organization_name: "organization",
+ organization_unit: "organizationUnit",
+ locality: "locality",
+ state: "state",
+ country: "country",
+ email: "email",
+ street_address: "streetAddress",
+ postal_code: "postalCode"
+ };
+
+ if (!templateAttributes || templateAttributes.length === 0) {
+ Object.entries(attributeMap).forEach(([templateKey, requestKey]) => {
+ const value = request[requestKey];
+ if (value && typeof value === "string") {
+ subject[templateKey] = value;
+ }
+ });
+ return subject;
+ }
+
+ templateAttributes.forEach((attr) => {
+ if (attr.include === "prohibit") {
+ return;
+ }
+
+ const requestKey = attributeMap[attr.type];
+ const value = request[requestKey];
+
+ if (value && typeof value === "string") {
+ subject[attr.type] = value;
+ }
+ });
+
+ return subject;
+};
+
+export const buildSubjectAlternativeNamesFromTemplate = (
+ request: { subjectAlternativeNames?: Array<{ type: string; value: string }> },
+ templateSans?: Array<{
+ type: string;
+ include: "mandatory" | "optional" | "prohibit";
+ value?: string[];
+ }>
+): string => {
+ if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) {
+ return "";
+ }
+
+ if (!templateSans || templateSans.length === 0) {
+ return request.subjectAlternativeNames.map((san) => san.value).join(",");
+ }
+
+ const allowedSans: string[] = [];
+ const prohibitedTypes = new Set(templateSans.filter((san) => san.include === "prohibit").map((san) => san.type));
+
+ request.subjectAlternativeNames.forEach((san) => {
+ const sanType = san.type === "dns_name" ? "dns_name" : san.type;
+ if (!prohibitedTypes.has(sanType)) {
+ allowedSans.push(san.value);
+ }
+ });
+
+ return allowedSans.join(",");
+};
diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts
new file mode 100644
index 000000000..8666ba9d6
--- /dev/null
+++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts
@@ -0,0 +1,294 @@
+import * as x509 from "@peculiar/x509";
+
+import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
+import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
+import { isCertChainValid } from "@app/services/certificate/certificate-fns";
+import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
+import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
+import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
+import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
+import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
+import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
+import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
+import { TKmsServiceFactory } from "@app/services/kms/kms-service";
+import { TProjectDALFactory } from "@app/services/project/project-dal";
+import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
+
+import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns";
+import { TLicenseServiceFactory } from "../../ee/services/license/license-service";
+
+type TCertificateEstV3ServiceFactoryDep = {
+ internalCertificateAuthorityService: Pick;
+ certificateTemplateDAL: Pick;
+ certificateAuthorityDAL: Pick;
+ certificateAuthorityCertDAL: Pick;
+ projectDAL: Pick;
+ kmsService: Pick;
+ licenseService: Pick;
+ certificateProfileDAL: Pick;
+ estEnrollmentConfigDAL: Pick;
+};
+
+export type TCertificateEstV3ServiceFactory = ReturnType;
+
+export const certificateEstV3ServiceFactory = ({
+ internalCertificateAuthorityService,
+ certificateTemplateDAL,
+ certificateAuthorityCertDAL,
+ certificateAuthorityDAL,
+ projectDAL,
+ kmsService,
+ licenseService,
+ certificateProfileDAL,
+ estEnrollmentConfigDAL
+}: TCertificateEstV3ServiceFactoryDep) => {
+ const simpleEnrollByProfile = async ({
+ csr,
+ profileId,
+ sslClientCert
+ }: {
+ csr: string;
+ profileId: string;
+ sslClientCert: string;
+ }) => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ if (!profile.estConfigId) {
+ throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
+ }
+
+ const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
+ if (!estConfig) {
+ throw new NotFoundError({ message: "EST configuration not found" });
+ }
+
+ const project = await projectDAL.findOne({ id: profile.projectId });
+ if (!project) {
+ throw new NotFoundError({ message: "Project not found" });
+ }
+
+ const plan = await licenseService.getPlan(project.orgId);
+ if (!plan.pkiEst) {
+ throw new BadRequestError({
+ message:
+ "Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan."
+ });
+ }
+
+ if (!estConfig.disableBootstrapCaValidation) {
+ const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
+ projectId: profile.projectId,
+ projectDAL,
+ kmsService
+ });
+
+ const kmsDecryptor = await kmsService.decryptWithKmsKey({
+ kmsId: certificateManagerKmsId
+ });
+
+ let decryptedCaChain = "";
+ if (estConfig.encryptedCaChain) {
+ decryptedCaChain = (
+ await kmsDecryptor({
+ cipherTextBlob: estConfig.encryptedCaChain
+ })
+ ).toString();
+ }
+
+ const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => {
+ return new x509.X509Certificate(cert);
+ });
+
+ if (!caCerts) {
+ throw new BadRequestError({ message: "Failed to parse certificate chain" });
+ }
+
+ const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
+
+ if (!leafCertificate) {
+ throw new BadRequestError({ message: "Missing client certificate" });
+ }
+
+ const certObj = new x509.X509Certificate(leafCertificate);
+ if (!(await isCertChainValid([certObj, ...caCerts]))) {
+ throw new BadRequestError({ message: "Invalid certificate chain" });
+ }
+ }
+
+ const { certificate } = await internalCertificateAuthorityService.signCertFromCa({
+ isInternal: true,
+ certificateTemplateId: profile.certificateTemplateId,
+ csr
+ });
+
+ return convertRawCertsToPkcs7([certificate.rawData]);
+ };
+
+ const simpleReenrollByProfile = async ({
+ csr,
+ profileId,
+ sslClientCert
+ }: {
+ csr: string;
+ profileId: string;
+ sslClientCert: string;
+ }) => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ if (!profile.estConfigId) {
+ throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
+ }
+
+ const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
+ if (!estConfig) {
+ throw new NotFoundError({ message: "EST configuration not found" });
+ }
+
+ const project = await projectDAL.findOne({ id: profile.projectId });
+ if (!project) {
+ throw new NotFoundError({ message: "Project not found" });
+ }
+
+ const plan = await licenseService.getPlan(project.orgId);
+ if (!plan.pkiEst) {
+ throw new BadRequestError({
+ message:
+ "Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan."
+ });
+ }
+
+ const certTemplate = await certificateTemplateDAL.findById(profile.certificateTemplateId);
+
+ const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
+
+ if (!leafCertificate) {
+ throw new UnauthorizedError({ message: "Missing client certificate" });
+ }
+
+ const cert = new x509.X509Certificate(leafCertificate);
+ const caCertChains = await getCaCertChains({
+ caId: certTemplate.caId,
+ certificateAuthorityCertDAL,
+ certificateAuthorityDAL,
+ projectDAL,
+ kmsService
+ });
+
+ const verifiedChains = await Promise.all(
+ caCertChains.map((chain) => {
+ const caCert = new x509.X509Certificate(chain.certificate);
+ const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || [];
+
+ return isCertChainValid([cert, caCert, ...caChain]);
+ })
+ );
+
+ if (!verifiedChains.some(Boolean)) {
+ throw new BadRequestError({
+ message: "Invalid client certificate: unable to build a valid certificate chain"
+ });
+ }
+
+ const csrObj = new x509.Pkcs10CertificateRequest(csr);
+ if (csrObj.subject !== cert.subject) {
+ throw new BadRequestError({
+ message: "Subject mismatch"
+ });
+ }
+
+ let csrSanSet: Set = new Set();
+ const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
+ if (csrSanExtension) {
+ const sanNames = new x509.GeneralNames(csrSanExtension.value);
+ csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]);
+ }
+
+ let certSanSet: Set = new Set();
+ const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17");
+ if (certSanExtension) {
+ const sanNames = new x509.GeneralNames(certSanExtension.value);
+ certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]);
+ }
+
+ if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) {
+ throw new BadRequestError({
+ message: "Subject alternative names mismatch"
+ });
+ }
+
+ const { certificate } = await internalCertificateAuthorityService.signCertFromCa({
+ isInternal: true,
+ certificateTemplateId: profile.certificateTemplateId,
+ csr
+ });
+
+ return convertRawCertsToPkcs7([certificate.rawData]);
+ };
+
+ const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ if (!profile.estConfigId) {
+ throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
+ }
+
+ const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
+ if (!estConfig) {
+ throw new NotFoundError({ message: "EST configuration not found" });
+ }
+
+ const project = await projectDAL.findOne({ id: profile.projectId });
+ if (!project) {
+ throw new NotFoundError({ message: "Project not found" });
+ }
+
+ const plan = await licenseService.getPlan(project.orgId);
+ if (!plan.pkiEst) {
+ throw new BadRequestError({
+ message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan."
+ });
+ }
+
+ const certTemplate = await certificateTemplateDAL.findById(profile.certificateTemplateId);
+ if (!certTemplate) {
+ throw new NotFoundError({
+ message: `Certificate template with ID '${profile.certificateTemplateId}' not found`
+ });
+ }
+
+ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId);
+ if (!ca?.internalCa?.id) {
+ throw new NotFoundError({
+ message: `Internal Certificate Authority with ID '${certTemplate.caId}' not found`
+ });
+ }
+
+ const { caCert, caCertChain } = await getCaCertChain({
+ caCertId: ca.internalCa.activeCaCertId as string,
+ certificateAuthorityDAL,
+ certificateAuthorityCertDAL,
+ projectDAL,
+ kmsService
+ });
+
+ const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert));
+
+ const caCertificate = new x509.X509Certificate(caCert);
+ return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]);
+ };
+
+ return {
+ simpleEnrollByProfile,
+ simpleReenrollByProfile,
+ getCaCertsByProfile
+ };
+};
diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts
new file mode 100644
index 000000000..6532f8588
--- /dev/null
+++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts
@@ -0,0 +1,336 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { DatabaseError } from "@app/lib/errors";
+import { ormify, selectAllTableCols } from "@app/lib/knex";
+
+import {
+ EnrollmentType,
+ TCertificateProfileCertificate,
+ TCertificateProfileInsert,
+ TCertificateProfileMetrics,
+ TCertificateProfileUpdate
+} from "./certificate-profile-types";
+
+export type TCertificateProfileDALFactory = ReturnType;
+
+export const certificateProfileDALFactory = (db: TDbClient) => {
+ const certificateProfileOrm = ormify(db, TableName.CertificateProfile);
+
+ const create = async (data: TCertificateProfileInsert, tx?: Knex) => {
+ try {
+ const [certificateProfile] = await (tx || db)(TableName.CertificateProfile).insert(data).returning("*");
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Create certificate profile" });
+ }
+ };
+
+ const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex) => {
+ try {
+ const [certificateProfile] = await (tx || db)(TableName.CertificateProfile)
+ .where({ id })
+ .update(data)
+ .returning("*");
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Update certificate profile" });
+ }
+ };
+
+ const deleteById = async (id: string, tx?: Knex) => {
+ try {
+ const [certificateProfile] = await (tx || db)(TableName.CertificateProfile).where({ id }).del().returning("*");
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Delete certificate profile" });
+ }
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const certificateProfile = await (tx || db)(TableName.CertificateProfile).where({ id }).first();
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate profile by id" });
+ }
+ };
+
+ const findByIdWithConfigs = async (id: string, tx?: Knex) => {
+ try {
+ const result = await (tx || db)(TableName.CertificateProfile)
+ .select(
+ selectAllTableCols(TableName.CertificateProfile),
+ (tx || db).ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
+ (tx || db).ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"),
+ (tx || db).ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"),
+ (tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
+ (tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
+ (tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"),
+ (tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"),
+ (tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
+ (tx || db).ref("id").withSchema(TableName.EstEnrollmentConfig).as("estConfigId"),
+ (tx || db)
+ .ref("disableBootstrapCaValidation")
+ .withSchema(TableName.EstEnrollmentConfig)
+ .as("estConfigDisableBootstrapCaValidation"),
+ (tx || db).ref("hashedPassphrase").withSchema(TableName.EstEnrollmentConfig).as("estConfigHashedPassphrase"),
+ (tx || db).ref("encryptedCaChain").withSchema(TableName.EstEnrollmentConfig).as("estConfigEncryptedCaChain"),
+ (tx || db).ref("id").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigId"),
+ (tx || db).ref("autoRenew").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenew"),
+ (tx || db).ref("autoRenewDays").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenewDays")
+ )
+ .leftJoin(
+ TableName.CertificateAuthority,
+ `${TableName.CertificateProfile}.caId`,
+ `${TableName.CertificateAuthority}.id`
+ )
+ .leftJoin(
+ TableName.CertificateTemplateV2,
+ `${TableName.CertificateProfile}.certificateTemplateId`,
+ `${TableName.CertificateTemplateV2}.id`
+ )
+ .leftJoin(
+ TableName.EstEnrollmentConfig,
+ `${TableName.CertificateProfile}.estConfigId`,
+ `${TableName.EstEnrollmentConfig}.id`
+ )
+ .leftJoin(
+ TableName.ApiEnrollmentConfig,
+ `${TableName.CertificateProfile}.apiConfigId`,
+ `${TableName.ApiEnrollmentConfig}.id`
+ )
+ .where(`${TableName.CertificateProfile}.id`, id)
+ .first();
+
+ return result;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate profile by id with configs" });
+ }
+ };
+
+ const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => {
+ try {
+ const certificateProfile = await (tx || db)(TableName.CertificateProfile).where({ slug, projectId }).first();
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" });
+ }
+ };
+
+ const findByProjectId = async (
+ projectId: string,
+ options: {
+ offset?: number;
+ limit?: number;
+ search?: string;
+ enrollmentType?: EnrollmentType;
+ caId?: string;
+ } = {},
+ tx?: Knex
+ ) => {
+ try {
+ const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
+
+ let query = (tx || db)(TableName.CertificateProfile).where(
+ `${TableName.CertificateProfile}.projectId`,
+ projectId
+ );
+
+ if (search) {
+ query = query.where((builder) => {
+ void builder
+ .whereILike(`${TableName.CertificateProfile}.name`, `%${search}%`)
+ .orWhereILike(`${TableName.CertificateProfile}.description`, `%${search}%`)
+ .orWhereILike(`${TableName.CertificateProfile}.slug`, `%${search}%`);
+ });
+ }
+
+ if (enrollmentType) {
+ query = query.where(`${TableName.CertificateProfile}.enrollmentType`, enrollmentType);
+ }
+
+ if (caId) {
+ query = query.where(`${TableName.CertificateProfile}.caId`, caId);
+ }
+
+ const certificateProfiles = await query
+ .select(selectAllTableCols(TableName.CertificateProfile))
+ .orderBy(`${TableName.CertificateProfile}.createdAt`, "desc")
+ .offset(offset)
+ .limit(limit);
+
+ return certificateProfiles;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate profiles by project id" });
+ }
+ };
+
+ const countByProjectId = async (
+ projectId: string,
+ options: {
+ search?: string;
+ enrollmentType?: EnrollmentType;
+ caId?: string;
+ } = {},
+ tx?: Knex
+ ) => {
+ try {
+ const { search, enrollmentType, caId } = options;
+
+ let query = (tx || db)(TableName.CertificateProfile).where({ projectId });
+
+ if (search) {
+ query = query.where((builder) => {
+ void builder
+ .whereILike("name", `%${search}%`)
+ .orWhereILike("description", `%${search}%`)
+ .orWhereILike("slug", `%${search}%`);
+ });
+ }
+
+ if (enrollmentType) {
+ query = query.where({ enrollmentType });
+ }
+
+ if (caId) {
+ query = query.where({ caId });
+ }
+
+ const result = await query.count("*").first();
+ return parseInt((result as unknown as { count: string }).count || "0", 10);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Count certificate profiles by project id" });
+ }
+ };
+
+ const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
+ try {
+ const certificateProfile = await (tx || db)(TableName.CertificateProfile).where({ name, projectId }).first();
+ return certificateProfile;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate profile by name and project id" });
+ }
+ };
+
+ const getCertificatesByProfile = async (
+ profileId: string,
+ options: {
+ offset?: number;
+ limit?: number;
+ status?: "active" | "expired" | "revoked";
+ search?: string;
+ } = {},
+ tx?: Knex
+ ): Promise => {
+ try {
+ const { offset = 0, limit = 20, status, search } = options;
+ const now = new Date();
+
+ let query = (tx || db)(TableName.Certificate).where("profileId", profileId);
+
+ if (search) {
+ query = query.where((builder) => {
+ void builder.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`);
+ });
+ }
+
+ if (status) {
+ switch (status) {
+ case "active":
+ query = query.where("notAfter", ">", now).where("isRevoked", false);
+ break;
+ case "expired":
+ query = query.where("notAfter", "<=", now).where("isRevoked", false);
+ break;
+ case "revoked":
+ query = query.where("isRevoked", true);
+ break;
+ default:
+ break;
+ }
+ }
+
+ const certificates = await query
+ .select((tx || db).ref("id").withSchema(TableName.Certificate))
+ .select((tx || db).ref("serialNumber").withSchema(TableName.Certificate))
+ .select((tx || db).ref("cn").withSchema(TableName.Certificate))
+ .select((tx || db).ref("status").withSchema(TableName.Certificate))
+ .select((tx || db).ref("notBefore").withSchema(TableName.Certificate))
+ .select((tx || db).ref("notAfter").withSchema(TableName.Certificate))
+ .select((tx || db).ref("isRevoked").withSchema(TableName.Certificate))
+ .select((tx || db).ref("createdAt").withSchema(TableName.Certificate))
+ .orderBy("createdAt", "desc")
+ .offset(offset)
+ .limit(limit);
+
+ return certificates;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Get certificates by profile" });
+ }
+ };
+
+ const getProfileMetrics = async (
+ profileId: string,
+ expiringDays: number = 30,
+ tx?: Knex
+ ): Promise => {
+ try {
+ const now = new Date();
+ const expiringDate = new Date();
+ expiringDate.setDate(now.getDate() + expiringDays);
+
+ const metrics = await (tx || db)(TableName.Certificate)
+ .where("profileId", profileId)
+ .select(
+ db.raw("COUNT(*) as total_certificates"),
+ db.raw("COUNT(CASE WHEN 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 NOT is_revoked AND not_after > ? AND not_after <= ? THEN 1 END) as expiring_certificates",
+ [now, expiringDate]
+ ),
+ db.raw("COUNT(CASE WHEN is_revoked THEN 1 END) as revoked_certificates")
+ )
+ .first();
+
+ return {
+ profileId,
+ totalCertificates: parseInt(String((metrics as Record)?.total_certificates || 0), 10),
+ activeCertificates: parseInt(String((metrics as Record)?.active_certificates || 0), 10),
+ expiredCertificates: parseInt(String((metrics as Record)?.expired_certificates || 0), 10),
+ expiringCertificates: parseInt(String((metrics as Record)?.expiring_certificates || 0), 10),
+ revokedCertificates: parseInt(String((metrics as Record)?.revoked_certificates || 0), 10)
+ };
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Get certificate profile metrics" });
+ }
+ };
+
+ const isProfileInUse = async (profileId: string, tx?: Knex) => {
+ try {
+ const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
+
+ return parseInt((doc as unknown as { count: string }).count || "0", 10);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Check if certificate profile is in use" });
+ }
+ };
+
+ return {
+ ...certificateProfileOrm,
+ create,
+ updateById,
+ deleteById,
+ findById,
+ findByIdWithConfigs,
+ findBySlugAndProjectId,
+ findByProjectId,
+ countByProjectId,
+ findByNameAndProjectId,
+ getCertificatesByProfile,
+ getProfileMetrics,
+ isProfileInUse
+ };
+};
diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts
new file mode 100644
index 000000000..978990d69
--- /dev/null
+++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts
@@ -0,0 +1,106 @@
+import RE2 from "re2";
+import { z } from "zod";
+
+import { EnrollmentType } from "./certificate-profile-types";
+
+export const createCertificateProfileSchema = z
+ .object({
+ projectId: z.string().min(1),
+ caId: z.string().uuid(),
+ certificateTemplateId: z.string().uuid(),
+ name: z.string().min(1).max(255),
+ slug: z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
+ description: z.string().max(1000).optional(),
+ enrollmentType: z.nativeEnum(EnrollmentType),
+ estConfig: z
+ .object({
+ disableBootstrapCaValidation: z.boolean().default(false),
+ passphrase: z.string().min(1),
+ encryptedCaChain: z.string()
+ })
+ .optional(),
+ apiConfig: z
+ .object({
+ autoRenew: z.boolean().default(false),
+ autoRenewDays: z.number().min(1).max(365).optional()
+ })
+ .optional()
+ })
+ .refine(
+ (data) => {
+ if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
+ return false;
+ }
+ if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Config must be provided based on enrollment type"
+ }
+ );
+
+export const updateCertificateProfileSchema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ slug: z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
+ .optional(),
+ description: z.string().max(1000).optional(),
+ enrollmentType: z.nativeEnum(EnrollmentType).optional(),
+ estConfig: z
+ .object({
+ disableBootstrapCaValidation: z.boolean().default(false),
+ passphrase: z.string().min(1),
+ encryptedCaChain: z.string()
+ })
+ .optional(),
+ apiConfig: z
+ .object({
+ autoRenew: z.boolean().default(false),
+ autoRenewDays: z.number().min(1).max(365).optional()
+ })
+ .optional()
+});
+
+export const getCertificateProfileByIdSchema = z.object({
+ id: z.string().uuid()
+});
+
+export const getCertificateProfileBySlugSchema = z.object({
+ projectId: z.string().min(1),
+ slug: z.string().min(1)
+});
+
+export const listCertificateProfilesSchema = z.object({
+ projectId: z.string().min(1),
+ offset: z.coerce.number().min(0).default(0),
+ limit: z.coerce.number().min(1).max(100).default(20),
+ search: z.string().optional(),
+ enrollmentType: z.nativeEnum(EnrollmentType).optional(),
+ caId: z.string().uuid().optional()
+});
+
+export const deleteCertificateProfileSchema = z.object({
+ id: z.string().uuid()
+});
+
+export const listCertificatesByProfileSchema = z.object({
+ profileId: z.string().uuid(),
+ offset: z.coerce.number().min(0).default(0),
+ limit: z.coerce.number().min(1).max(100).default(20),
+ status: z.enum(["active", "expired", "revoked"]).optional(),
+ search: z.string().optional()
+});
+
+export const getCertificateProfileMetricsSchema = z.object({
+ profileId: z.string().uuid(),
+ expiringDays: z.coerce.number().min(1).max(365).default(30)
+});
diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts
new file mode 100644
index 000000000..42bdc0a2e
--- /dev/null
+++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts
@@ -0,0 +1,1129 @@
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+import { ForbiddenError } from "@casl/ability";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+
+import { ActorType, AuthMethod } from "../auth/auth-type";
+import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
+import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
+import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
+import type { TCertificateProfileDALFactory } from "./certificate-profile-dal";
+import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service";
+import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types";
+
+vi.mock("@app/lib/crypto/cryptography", () => ({
+ crypto: {
+ hashing: () => ({
+ createHash: vi.fn().mockResolvedValue("mocked-hash")
+ }),
+ generateRandomPassword: vi.fn().mockReturnValue("mocked-password")
+ }
+}));
+
+vi.mock("@app/lib/config/env", () => ({
+ getConfig: () => ({
+ SALT_ROUNDS: 12
+ })
+}));
+
+describe("CertificateProfileService", () => {
+ let service: TCertificateProfileServiceFactory;
+
+ const mockCertificateProfileDAL = {
+ create: vi.fn(),
+ findById: vi.fn(),
+ updateById: vi.fn(),
+ deleteById: vi.fn(),
+ findBySlugAndProjectId: vi.fn(),
+ findByProjectId: vi.fn(),
+ countByProjectId: vi.fn(),
+ findByNameAndProjectId: vi.fn(),
+ findByIdWithConfigs: vi.fn(),
+ getCertificatesByProfile: vi.fn(),
+ getProfileMetrics: vi.fn(),
+ isProfileInUse: vi.fn(),
+ transaction: vi.fn(),
+ find: vi.fn(),
+ findOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn()
+ } as unknown as TCertificateProfileDALFactory;
+
+ const mockCertificateTemplateV2DAL = {
+ findById: vi.fn(),
+ create: vi.fn(),
+ updateById: vi.fn(),
+ deleteById: vi.fn(),
+ findByProjectId: vi.fn(),
+ countByProjectId: vi.fn(),
+ isTemplateInUse: vi.fn(),
+ findByNameAndProjectId: vi.fn(),
+ transaction: vi.fn(),
+ find: vi.fn(),
+ findOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn()
+ } as unknown as TCertificateTemplateV2DALFactory;
+
+ const mockActor = {
+ actor: ActorType.USER,
+ actorId: "user-123",
+ actorAuthMethod: AuthMethod.EMAIL,
+ actorOrgId: "org-123"
+ };
+
+ const sampleProfile: TCertificateProfile = {
+ id: "profile-123",
+ projectId: "project-123",
+ name: "Test Profile",
+ description: "Test certificate profile",
+ slug: "test-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfigId: "api-config-123",
+ estConfigId: null,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ const sampleProfileWithConfigs: TCertificateProfileWithConfigs = {
+ ...sampleProfile,
+ certificateAuthority: {
+ id: "ca-123",
+ projectId: "project-123",
+ status: "active",
+ name: "Test CA"
+ },
+ certificateTemplate: {
+ id: "template-123",
+ projectId: "project-123",
+ name: "Test Template",
+ description: "Test template"
+ },
+ apiConfig: {
+ id: "api-config-123",
+ autoRenew: true,
+ autoRenewDays: 30
+ }
+ };
+
+ const sampleTemplate = {
+ id: "template-123",
+ projectId: "project-123",
+ name: "Test Template"
+ };
+
+ const mockApiEnrollmentConfigDAL = {
+ create: vi.fn().mockResolvedValue({ id: "api-config-123" }),
+ findById: vi.fn(),
+ updateById: vi.fn(),
+ deleteById: vi.fn(),
+ findProfilesForAutoRenewal: vi.fn(),
+ isConfigInUse: vi.fn(),
+ transaction: vi.fn(),
+ find: vi.fn(),
+ findOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn()
+ } as unknown as TApiEnrollmentConfigDALFactory;
+
+ const mockEstEnrollmentConfigDAL = {
+ create: vi.fn().mockResolvedValue({ id: "est-config-123" }),
+ findById: vi.fn(),
+ updateById: vi.fn(),
+ deleteById: vi.fn(),
+ isConfigInUse: vi.fn(),
+ transaction: vi.fn(),
+ find: vi.fn(),
+ findOne: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn()
+ } as unknown as TEstEnrollmentConfigDALFactory;
+
+ const mockPermissionService = {
+ getProjectPermission: vi.fn().mockResolvedValue({
+ permission: {
+ throwUnlessCan: vi.fn()
+ }
+ })
+ } as unknown as Pick;
+
+ beforeEach(() => {
+ vi.spyOn(ForbiddenError, "from").mockReturnValue({
+ throwUnlessCan: vi.fn()
+ } as any);
+
+ service = certificateProfileServiceFactory({
+ certificateProfileDAL: mockCertificateProfileDAL,
+ certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
+ apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
+ estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
+ permissionService: mockPermissionService
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("createProfile", () => {
+ const validProfileData = {
+ name: "New Profile",
+ description: "New test profile",
+ slug: "new-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: true,
+ autoRenewDays: 30
+ }
+ };
+
+ beforeEach(() => {
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.create as any).mockResolvedValue(sampleProfile);
+ });
+
+ it("should create profile successfully", async () => {
+ const result = await service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: validProfileData
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123");
+ expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith({
+ name: "New Profile",
+ description: "New test profile",
+ slug: "new-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfigId: "api-config-123",
+ estConfigId: null,
+ projectId: "project-123"
+ });
+ });
+
+ it("should throw NotFoundError when certificate template not found", async () => {
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: validProfileData
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("should throw ForbiddenRequestError when template belongs to different project", async () => {
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue({
+ ...sampleTemplate,
+ projectId: "different-project"
+ });
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: validProfileData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should throw ForbiddenRequestError when profile slug already exists", async () => {
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile);
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: validProfileData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should throw ForbiddenRequestError for EST enrollment without EST config", async () => {
+ const invalidData = {
+ ...validProfileData,
+ enrollmentType: EnrollmentType.EST,
+ estConfigId: null
+ };
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should throw ForbiddenRequestError for API enrollment without API config", async () => {
+ const invalidData = {
+ name: "Invalid Profile",
+ description: "Invalid test profile",
+ slug: "invalid-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should create profile with API enrollment", async () => {
+ const apiProfileData = {
+ name: "API Profile",
+ description: "Profile with API enrollment",
+ slug: "api-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: true,
+ autoRenewDays: 30
+ }
+ };
+
+ const result = await service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: apiProfileData
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ });
+ });
+
+ describe("updateProfile", () => {
+ const updateData = {
+ name: "Updated Profile",
+ description: "Updated description"
+ };
+
+ beforeEach(() => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ ...sampleProfile, ...updateData });
+ });
+
+ it("should update profile successfully", async () => {
+ const result = await service.updateProfile({
+ ...mockActor,
+ profileId: "profile-123",
+ data: updateData
+ });
+
+ expect(result.name).toBe("Updated Profile");
+ expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData);
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.updateProfile({
+ ...mockActor,
+ profileId: "profile-123",
+ data: updateData
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("should validate certificate template when updating", async () => {
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+
+ const updateWithTemplate = {
+ ...updateData,
+ certificateTemplateId: "template-123"
+ };
+
+ await service.updateProfile({
+ ...mockActor,
+ profileId: "profile-123",
+ data: updateWithTemplate
+ });
+
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ });
+ });
+
+ describe("getProfileById", () => {
+ it("should return profile successfully", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+
+ const result = await service.getProfileById({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.getProfileById({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("getProfileByIdWithConfigs", () => {
+ it("should return profile with configs successfully", async () => {
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(sampleProfileWithConfigs);
+
+ const result = await service.getProfileByIdWithConfigs({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(sampleProfileWithConfigs);
+ expect(mockCertificateProfileDAL.findByIdWithConfigs).toHaveBeenCalledWith("profile-123");
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null);
+
+ await expect(
+ service.getProfileByIdWithConfigs({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("getProfileBySlug", () => {
+ it("should return profile by slug successfully", async () => {
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile);
+
+ const result = await service.getProfileBySlug({
+ ...mockActor,
+ projectId: "project-123",
+ slug: "test-profile"
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("test-profile", "project-123");
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+
+ await expect(
+ service.getProfileBySlug({
+ ...mockActor,
+ projectId: "project-123",
+ slug: "nonexistent"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("listProfiles", () => {
+ const mockProfiles = [sampleProfile];
+
+ beforeEach(() => {
+ (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfiles);
+ (mockCertificateProfileDAL.countByProjectId as any).mockResolvedValue(1);
+ });
+
+ it("should list profiles successfully", async () => {
+ const result = await service.listProfiles({
+ ...mockActor,
+ projectId: "project-123"
+ });
+
+ expect(result.profiles).toEqual(mockProfiles);
+ expect(result.totalCount).toBe(1);
+ expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", {
+ offset: 0,
+ limit: 20,
+ search: undefined,
+ enrollmentType: undefined,
+ caId: undefined
+ });
+ });
+
+ it("should list profiles with filters", async () => {
+ await service.listProfiles({
+ ...mockActor,
+ projectId: "project-123",
+ offset: 10,
+ limit: 5,
+ search: "test",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123"
+ });
+
+ expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", {
+ offset: 10,
+ limit: 5,
+ search: "test",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123"
+ });
+ });
+ });
+
+ describe("deleteProfile", () => {
+ beforeEach(() => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false);
+ (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile);
+ });
+
+ it("should delete profile successfully", async () => {
+ const result = await service.deleteProfile({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.isProfileInUse).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123");
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.deleteProfile({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("should throw ForbiddenRequestError when profile is in use", async () => {
+ (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(true);
+
+ await expect(
+ service.deleteProfile({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ expect(mockCertificateProfileDAL.deleteById).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("getProfileCertificates", () => {
+ const mockCertificates = [
+ {
+ id: "cert-123",
+ serialNumber: "123456",
+ cn: "example.com",
+ status: "active",
+ notBefore: new Date(),
+ notAfter: new Date(),
+ isRevoked: false,
+ createdAt: new Date()
+ }
+ ];
+
+ beforeEach(() => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(mockCertificates);
+ });
+
+ it("should get profile certificates successfully", async () => {
+ const result = await service.getProfileCertificates({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(mockCertificates);
+ expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", {
+ offset: 0,
+ limit: 20,
+ status: undefined,
+ search: undefined
+ });
+ });
+
+ it("should get profile certificates with filters", async () => {
+ await service.getProfileCertificates({
+ ...mockActor,
+ profileId: "profile-123",
+ offset: 10,
+ limit: 5,
+ status: "active",
+ search: "example"
+ });
+
+ expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", {
+ offset: 10,
+ limit: 5,
+ status: "active",
+ search: "example"
+ });
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.getProfileCertificates({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("getProfileMetrics", () => {
+ const mockMetrics = {
+ profileId: "profile-123",
+ totalCertificates: 10,
+ activeCertificates: 8,
+ expiredCertificates: 1,
+ expiringCertificates: 2,
+ revokedCertificates: 1
+ };
+
+ beforeEach(() => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics);
+ });
+
+ it("should get profile metrics successfully", async () => {
+ const result = await service.getProfileMetrics({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(mockMetrics);
+ expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30);
+ });
+
+ it("should get profile metrics with custom expiring days", async () => {
+ await service.getProfileMetrics({
+ ...mockActor,
+ profileId: "profile-123",
+ expiringDays: 60
+ });
+
+ expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60);
+ });
+
+ it("should throw NotFoundError when profile not found", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.getProfileMetrics({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("comprehensive certificate profile scenarios", () => {
+ describe("profile configuration validation", () => {
+ it("should validate EST enrollment configuration", async () => {
+ const estProfileData = {
+ name: "EST Profile",
+ description: "Profile with EST enrollment",
+ slug: "est-profile",
+ enrollmentType: EnrollmentType.EST,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ estConfig: {
+ disableBootstrapCaValidation: false,
+ passphrase: "secret-passphrase",
+ encryptedCaChain: "encrypted-ca-chain-data"
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.create as any).mockResolvedValue({
+ ...sampleProfile,
+ enrollmentType: EnrollmentType.EST,
+ estConfigId: "est-config-123"
+ });
+
+ const result = await service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: estProfileData
+ });
+
+ expect(result.enrollmentType).toBe(EnrollmentType.EST);
+ expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith({
+ disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation,
+ hashedPassphrase: "mocked-hash",
+ encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64")
+ });
+ });
+
+ it("should handle profile slug uniqueness validation", async () => {
+ vi.clearAllMocks();
+
+ const duplicateSlugData = {
+ name: "Different Profile Name",
+ description: "Profile with duplicate slug",
+ slug: "test-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: true,
+ autoRenewDays: 30
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile);
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: duplicateSlugData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should validate auto-renewal configuration", async () => {
+ const autoRenewData = {
+ name: "Auto Renew Profile",
+ description: "Profile with auto-renewal",
+ slug: "auto-renew",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: true,
+ autoRenewDays: 7
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.create as any).mockResolvedValue({
+ ...sampleProfile,
+ apiConfigId: "api-config-123"
+ });
+
+ const result = await service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: autoRenewData
+ });
+
+ expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith({
+ autoRenew: true,
+ autoRenewDays: 7
+ });
+ expect(result).toBeDefined();
+ });
+ });
+
+ describe("profile lifecycle management", () => {
+ it("should handle profile updates with enrollment type changes", async () => {
+ const currentProfile = {
+ ...sampleProfile,
+ enrollmentType: EnrollmentType.API,
+ apiConfigId: "api-config-123",
+ estConfigId: null
+ };
+
+ const updateToEst = {
+ enrollmentType: EnrollmentType.EST,
+ estConfigId: "est-config-123",
+ apiConfigId: null
+ };
+
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(currentProfile);
+ (mockCertificateProfileDAL.updateById as any).mockResolvedValue({
+ ...currentProfile,
+ enrollmentType: EnrollmentType.EST,
+ estConfigId: "est-config-123",
+ apiConfigId: null
+ });
+
+ const result = await service.updateProfile({
+ ...mockActor,
+ profileId: "profile-123",
+ data: updateToEst
+ });
+
+ expect(mockEstEnrollmentConfigDAL.create).not.toHaveBeenCalled();
+ expect(result.enrollmentType).toBe(EnrollmentType.EST);
+ });
+
+ it("should prevent deletion of profiles with active certificates", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(true);
+
+ await expect(
+ service.deleteProfile({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+
+ expect(mockCertificateProfileDAL.deleteById).not.toHaveBeenCalled();
+ });
+
+ it("should allow deletion of unused profiles", async () => {
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false);
+ (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile);
+
+ const result = await service.deleteProfile({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result).toEqual(sampleProfile);
+ expect(mockCertificateProfileDAL.isProfileInUse).toHaveBeenCalledWith("profile-123");
+ expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123");
+ });
+ });
+
+ describe("certificate management", () => {
+ it("should filter certificates by status", async () => {
+ const activeCerts = [
+ {
+ id: "cert-1",
+ serialNumber: "123456",
+ cn: "example.com",
+ status: "active",
+ notBefore: new Date(),
+ notAfter: new Date(),
+ isRevoked: false,
+ createdAt: new Date()
+ }
+ ];
+
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(activeCerts);
+
+ const result = await service.getProfileCertificates({
+ ...mockActor,
+ profileId: "profile-123",
+ status: "active"
+ });
+
+ expect(result).toEqual(activeCerts);
+ expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", {
+ offset: 0,
+ limit: 20,
+ status: "active",
+ search: undefined
+ });
+ });
+
+ it("should search certificates by common name", async () => {
+ const searchResults = [
+ {
+ id: "cert-1",
+ serialNumber: "123456",
+ cn: "api.example.com",
+ status: "active",
+ notBefore: new Date(),
+ notAfter: new Date(),
+ isRevoked: false,
+ createdAt: new Date()
+ }
+ ];
+
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(searchResults);
+
+ const result = await service.getProfileCertificates({
+ ...mockActor,
+ profileId: "profile-123",
+ search: "api.example"
+ });
+
+ expect(result).toEqual(searchResults);
+ expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", {
+ offset: 0,
+ limit: 20,
+ status: undefined,
+ search: "api.example"
+ });
+ });
+ });
+
+ describe("metrics and monitoring", () => {
+ it("should calculate profile metrics correctly", async () => {
+ const detailedMetrics = {
+ profileId: "profile-123",
+ totalCertificates: 50,
+ activeCertificates: 40,
+ expiredCertificates: 5,
+ expiringCertificates: 3,
+ revokedCertificates: 2
+ };
+
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics);
+
+ const result = await service.getProfileMetrics({
+ ...mockActor,
+ profileId: "profile-123",
+ expiringDays: 14
+ });
+
+ expect(result).toEqual(detailedMetrics);
+ expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14);
+ });
+
+ it("should handle zero certificate metrics", async () => {
+ const emptyMetrics = {
+ profileId: "profile-123",
+ totalCertificates: 0,
+ activeCertificates: 0,
+ expiredCertificates: 0,
+ expiringCertificates: 0,
+ revokedCertificates: 0
+ };
+
+ (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
+ (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics);
+
+ const result = await service.getProfileMetrics({
+ ...mockActor,
+ profileId: "profile-123"
+ });
+
+ expect(result.totalCertificates).toBe(0);
+ expect(result.activeCertificates).toBe(0);
+ });
+ });
+
+ describe("error scenarios", () => {
+ it("should handle database connection errors gracefully", async () => {
+ (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed"));
+
+ await expect(
+ service.getProfileById({
+ ...mockActor,
+ profileId: "profile-123"
+ })
+ ).rejects.toThrow("Database connection failed");
+ });
+
+ it("should handle invalid template reference during profile creation", async () => {
+ const profileData = {
+ name: "Invalid Template Profile",
+ description: "Profile with invalid template",
+ slug: "invalid-template",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "nonexistent-template",
+ apiConfig: {
+ autoRenew: false
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null);
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: profileData
+ })
+ ).rejects.toThrow(NotFoundError);
+
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("nonexistent-template");
+ });
+
+ it("should handle concurrent profile creation conflicts", async () => {
+ const conflictingData = {
+ name: "Concurrent Profile",
+ description: "Profile created concurrently",
+ slug: "concurrent-profile",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: false
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.create as any).mockRejectedValue(new Error("Unique constraint violation"));
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: conflictingData
+ })
+ ).rejects.toThrow("Unique constraint violation");
+ });
+ });
+
+ describe("permission and security", () => {
+ it("should validate project ownership for cross-project template access", async () => {
+ const crossProjectData = {
+ name: "Cross Project Profile",
+ description: "Profile using template from different project",
+ slug: "cross-project",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-456",
+ apiConfig: {
+ autoRenew: false
+ }
+ };
+
+ const foreignTemplate = {
+ id: "template-456",
+ projectId: "different-project-456",
+ name: "Foreign Template"
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate);
+
+ await expect(
+ service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: crossProjectData
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ });
+
+ it("should validate slug format constraints", async () => {
+ const invalidSlugData = {
+ name: "Invalid Slug Profile",
+ description: "Profile with invalid slug format",
+ slug: "Invalid_Slug_With_Underscores_And_Caps",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123",
+ apiConfig: {
+ autoRenew: false
+ }
+ };
+
+ (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
+ (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
+ (mockCertificateProfileDAL.create as any).mockResolvedValue({
+ ...sampleProfile,
+ slug: invalidSlugData.slug
+ });
+
+ const result = await service.createProfile({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidSlugData
+ });
+
+ expect(result.slug).toBe(invalidSlugData.slug);
+ });
+ });
+ });
+
+ describe("getEstConfigurationByProfile", () => {
+ it("should return EST configuration for valid EST profile", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ ...sampleProfileWithConfigs,
+ id: profileId,
+ enrollmentType: EnrollmentType.EST,
+ estConfigEncryptedCaChain: Buffer.from("mock-ca-chain"),
+ estConfigDisableBootstrapCaValidation: false,
+ estConfigHashedPassphrase: "hashed-passphrase"
+ } as TCertificateProfileWithConfigs;
+
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile);
+
+ const result = await service.getEstConfigurationByProfile({ profileId });
+
+ expect(result).toEqual({
+ orgId: "project-123",
+ isEnabled: true,
+ caChain: "bW9jay1jYS1jaGFpbg==", // base64 encoded
+ disableBootstrapCertValidation: false,
+ hashedPassphrase: "hashed-passphrase"
+ });
+ });
+
+ it("should throw NotFoundError when profile doesn't exist", async () => {
+ const profileId = "non-existent-profile";
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null);
+
+ await expect(service.getEstConfigurationByProfile({ profileId })).rejects.toThrow(NotFoundError);
+ });
+
+ it("should throw ForbiddenRequestError when profile is not configured for EST enrollment", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ ...sampleProfileWithConfigs,
+ id: profileId,
+ enrollmentType: EnrollmentType.API, // Wrong enrollment type
+ estConfigEncryptedCaChain: Buffer.from("mock-ca-chain"),
+ estConfigDisableBootstrapCaValidation: false,
+ estConfigHashedPassphrase: "hashed-passphrase"
+ } as TCertificateProfileWithConfigs;
+
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile);
+
+ await expect(service.getEstConfigurationByProfile({ profileId })).rejects.toThrow(ForbiddenRequestError);
+ await expect(service.getEstConfigurationByProfile({ profileId })).rejects.toThrow(
+ "Profile is not configured for EST enrollment"
+ );
+ });
+
+ it("should throw NotFoundError when EST configuration is missing", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ ...sampleProfileWithConfigs,
+ id: profileId,
+ enrollmentType: EnrollmentType.EST,
+ estConfigEncryptedCaChain: null, // Missing EST config
+ estConfigDisableBootstrapCaValidation: false,
+ estConfigHashedPassphrase: "hashed-passphrase"
+ } as TCertificateProfileWithConfigs;
+
+ (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile);
+
+ await expect(service.getEstConfigurationByProfile({ profileId })).rejects.toThrow(NotFoundError);
+ await expect(service.getEstConfigurationByProfile({ profileId })).rejects.toThrow(
+ "EST configuration not found for this profile"
+ );
+ });
+ });
+});
diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts
new file mode 100644
index 000000000..f51929865
--- /dev/null
+++ b/backend/src/services/certificate-profile/certificate-profile-service.ts
@@ -0,0 +1,622 @@
+import { ForbiddenError } from "@casl/ability";
+
+import { ActionProjectType } from "@app/db/schemas";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import {
+ ProjectPermissionCertificateProfileActions,
+ ProjectPermissionSub
+} from "@app/ee/services/permission/project-permission";
+import { getConfig } from "@app/lib/config/env";
+import { crypto } from "@app/lib/crypto/cryptography";
+import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+
+import { ActorAuthMethod, ActorType } from "../auth/auth-type";
+import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
+import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
+import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
+import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
+import { TCertificateProfileDALFactory } from "./certificate-profile-dal";
+import {
+ EnrollmentType,
+ TCertificateProfile,
+ TCertificateProfileCertificate,
+ TCertificateProfileInsert,
+ TCertificateProfileMetrics,
+ TCertificateProfileUpdate,
+ TCertificateProfileWithConfigs
+} from "./certificate-profile-types";
+
+export type TCertificateProfileCreateData = Omit & {
+ estConfig?: TEstConfigData;
+ apiConfig?: TApiConfigData;
+};
+
+type TCertificateProfileServiceFactoryDep = {
+ certificateProfileDAL: TCertificateProfileDALFactory;
+ certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
+ apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
+ estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
+ permissionService: Pick;
+};
+
+export type TCertificateProfileServiceFactory = ReturnType;
+
+const convertDalToService = (dalResult: Record): TCertificateProfile => {
+ return {
+ ...dalResult,
+ enrollmentType: dalResult.enrollmentType as EnrollmentType
+ } as TCertificateProfile;
+};
+
+const convertDalArrayToService = (dalResults: Record[]): TCertificateProfile[] => {
+ return dalResults.map(convertDalToService);
+};
+
+const validateEnrollmentConfig = async (data: {
+ enrollmentType: EnrollmentType;
+ estConfig?: TEstConfigData | null;
+ apiConfig?: TApiConfigData | null;
+}): Promise => {
+ if (data.enrollmentType === EnrollmentType.EST) {
+ if (!data.estConfig) {
+ throw new ForbiddenRequestError({
+ message: "EST enrollment type requires EST configuration"
+ });
+ }
+ if (data.apiConfig) {
+ throw new ForbiddenRequestError({
+ message: "EST enrollment type cannot have API configuration"
+ });
+ }
+ } else if (data.enrollmentType === EnrollmentType.API) {
+ if (!data.apiConfig) {
+ throw new ForbiddenRequestError({
+ message: "API enrollment type requires API configuration"
+ });
+ }
+ if (data.estConfig) {
+ throw new ForbiddenRequestError({
+ message: "API enrollment type cannot have EST configuration"
+ });
+ }
+ }
+};
+
+const validateEnrollmentConfigForUpdate = async (data: {
+ enrollmentType: EnrollmentType;
+ estConfigId?: string | null;
+ apiConfigId?: string | null;
+}): Promise => {
+ if (data.enrollmentType === EnrollmentType.EST) {
+ if (!data.estConfigId) {
+ throw new ForbiddenRequestError({
+ message: "EST enrollment type requires EST configuration ID"
+ });
+ }
+ if (data.apiConfigId) {
+ throw new ForbiddenRequestError({
+ message: "EST enrollment type cannot have API configuration ID"
+ });
+ }
+ } else if (data.enrollmentType === EnrollmentType.API) {
+ if (!data.apiConfigId) {
+ throw new ForbiddenRequestError({
+ message: "API enrollment type requires API configuration ID"
+ });
+ }
+ if (data.estConfigId) {
+ throw new ForbiddenRequestError({
+ message: "API enrollment type cannot have EST configuration ID"
+ });
+ }
+ }
+};
+
+const hasEnrollmentConfigChanges = (data: TCertificateProfileUpdate): boolean => {
+ return !!(data.enrollmentType || data.estConfigId || data.apiConfigId);
+};
+
+export const certificateProfileServiceFactory = ({
+ certificateProfileDAL,
+ certificateTemplateV2DAL,
+ apiEnrollmentConfigDAL,
+ estEnrollmentConfigDAL,
+ permissionService
+}: TCertificateProfileServiceFactoryDep) => {
+ const createProfile = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ data
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ data: Omit;
+ }): Promise => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Create,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ // Validate that certificate template exists and belongs to the same project
+ if (data.certificateTemplateId) {
+ const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+ if (template.projectId !== projectId) {
+ throw new ForbiddenRequestError({
+ message: "Certificate template must belong to the same project"
+ });
+ }
+ }
+
+ // Check for slug uniqueness within project
+ const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId);
+ if (existingSlugProfile) {
+ throw new ForbiddenRequestError({
+ message: "Certificate profile with this slug already exists in project"
+ });
+ }
+
+ // Validate enrollment type configuration
+ await validateEnrollmentConfig({
+ enrollmentType: data.enrollmentType,
+ estConfig: data.estConfig,
+ apiConfig: data.apiConfig
+ });
+
+ // Create enrollment configs based on type
+ let estConfigId: string | null = null;
+ let apiConfigId: string | null = null;
+
+ if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
+ const appCfg = getConfig();
+ // Hash the passphrase
+ const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS);
+
+ const estConfig = await estEnrollmentConfigDAL.create({
+ disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation,
+ hashedPassphrase,
+ encryptedCaChain: Buffer.from(data.estConfig.encryptedCaChain, "base64")
+ });
+ estConfigId = estConfig.id;
+ } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) {
+ const apiConfig = await apiEnrollmentConfigDAL.create({
+ autoRenew: data.apiConfig.autoRenew,
+ autoRenewDays: data.apiConfig.autoRenewDays
+ });
+ apiConfigId = apiConfig.id;
+ }
+
+ // Create the profile with the created config IDs
+ const { estConfig, apiConfig, ...profileData } = data;
+ const profile = await certificateProfileDAL.create({
+ ...profileData,
+ projectId,
+ estConfigId,
+ apiConfigId
+ });
+
+ return convertDalToService(profile);
+ };
+
+ const updateProfile = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId,
+ data
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ data: TCertificateProfileUpdate;
+ }): Promise => {
+ const existingProfile = await certificateProfileDAL.findById(profileId);
+ if (!existingProfile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: existingProfile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Edit,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ if (data.certificateTemplateId) {
+ const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+ if (template.projectId !== existingProfile.projectId) {
+ throw new ForbiddenRequestError({
+ message: "Certificate template must belong to the same project"
+ });
+ }
+ }
+
+ if (data.slug && data.slug !== existingProfile.slug) {
+ const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId(
+ data.slug,
+ existingProfile.projectId
+ );
+ if (conflictingProfile && conflictingProfile.id !== profileId) {
+ throw new ForbiddenRequestError({
+ message: "Certificate profile with this slug already exists in project"
+ });
+ }
+ }
+
+ if (hasEnrollmentConfigChanges(data)) {
+ const mergedData = { ...existingProfile, ...data };
+ await validateEnrollmentConfigForUpdate({
+ enrollmentType: mergedData.enrollmentType as EnrollmentType,
+ estConfigId: mergedData.estConfigId,
+ apiConfigId: mergedData.apiConfigId
+ });
+ }
+
+ const updatedProfile = await certificateProfileDAL.updateById(profileId, data);
+ return convertDalToService(updatedProfile);
+ };
+
+ const getProfileById = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ }): Promise => {
+ const profile = await certificateProfileDAL.findById(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ return convertDalToService(profile);
+ };
+
+ const getProfileByIdWithConfigs = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ }): Promise => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ return {
+ ...profile,
+ enrollmentType: profile.enrollmentType as EnrollmentType
+ };
+ };
+
+ const getProfileBySlug = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ slug
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ slug: string;
+ }): Promise => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ return convertDalToService(profile);
+ };
+
+ const listProfiles = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ offset = 0,
+ limit = 20,
+ search,
+ enrollmentType,
+ caId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ offset?: number;
+ limit?: number;
+ search?: string;
+ enrollmentType?: EnrollmentType;
+ caId?: string;
+ }): Promise<{
+ profiles: TCertificateProfile[];
+ totalCount: number;
+ }> => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ const profiles = await certificateProfileDAL.findByProjectId(projectId, {
+ offset,
+ limit,
+ search,
+ enrollmentType,
+ caId
+ });
+
+ const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
+ search,
+ enrollmentType,
+ caId
+ });
+
+ return {
+ profiles: convertDalArrayToService(profiles),
+ totalCount
+ };
+ };
+
+ const deleteProfile = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ }): Promise => {
+ const profile = await certificateProfileDAL.findById(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Delete,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ // Check if profile is in use by any certificates
+ const isInUse = await certificateProfileDAL.isProfileInUse(profileId);
+ if (isInUse) {
+ throw new ForbiddenRequestError({
+ message: "Cannot delete certificate profile that has issued certificates"
+ });
+ }
+
+ const deletedProfile = await certificateProfileDAL.deleteById(profileId);
+ if (!deletedProfile) {
+ throw new NotFoundError({ message: "Failed to delete certificate profile" });
+ }
+ return convertDalToService(deletedProfile);
+ };
+
+ const getProfileCertificates = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId,
+ offset = 0,
+ limit = 20,
+ status,
+ search
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ offset?: number;
+ limit?: number;
+ status?: "active" | "expired" | "revoked";
+ search?: string;
+ }): Promise => {
+ const profile = await certificateProfileDAL.findById(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, {
+ offset,
+ limit,
+ status,
+ search
+ });
+
+ return certificates;
+ };
+
+ const getProfileMetrics = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ profileId,
+ expiringDays = 30
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ profileId: string;
+ expiringDays?: number;
+ }): Promise => {
+ const profile = await certificateProfileDAL.findById(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.Read,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
+ return metrics;
+ };
+
+ const getEstConfigurationByProfile = async ({ profileId }: { profileId: string }) => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ if (profile.enrollmentType !== EnrollmentType.EST) {
+ throw new ForbiddenRequestError({
+ message: "Profile is not configured for EST enrollment"
+ });
+ }
+
+ if (!profile.estConfigEncryptedCaChain) {
+ throw new NotFoundError({ message: "EST configuration not found for this profile" });
+ }
+
+ return {
+ orgId: profile.projectId,
+ isEnabled: true,
+ caChain: profile.estConfigEncryptedCaChain.toString("base64"),
+ disableBootstrapCertValidation: profile.estConfigDisableBootstrapCaValidation,
+ hashedPassphrase: profile.estConfigHashedPassphrase
+ };
+ };
+
+ return {
+ createProfile,
+ updateProfile,
+ getProfileById,
+ getProfileByIdWithConfigs,
+ getProfileBySlug,
+ listProfiles,
+ deleteProfile,
+ getProfileCertificates,
+ getProfileMetrics,
+ getEstConfigurationByProfile
+ };
+};
diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts
new file mode 100644
index 000000000..4eb8c8b90
--- /dev/null
+++ b/backend/src/services/certificate-profile/certificate-profile-types.ts
@@ -0,0 +1,68 @@
+import {
+ TCertificateProfiles,
+ TCertificateProfilesInsert,
+ TCertificateProfilesUpdate
+} from "@app/db/schemas/certificate-profiles";
+
+export enum EnrollmentType {
+ API = "api",
+ EST = "est"
+}
+
+export type TCertificateProfile = Omit & {
+ enrollmentType: EnrollmentType;
+};
+
+export type TCertificateProfileInsert = Omit & {
+ enrollmentType: EnrollmentType;
+};
+
+export type TCertificateProfileUpdate = Omit & {
+ enrollmentType?: EnrollmentType;
+};
+
+export type TCertificateProfileWithConfigs = TCertificateProfile & {
+ certificateAuthority?: {
+ id: string;
+ projectId: string;
+ status: string;
+ name: string;
+ };
+ certificateTemplate?: {
+ id: string;
+ projectId: string;
+ name: string;
+ description?: string;
+ };
+ estConfig?: {
+ id: string;
+ disableBootstrapCaValidation: boolean;
+ hashedPassphrase: string;
+ encryptedCaChain: Buffer;
+ };
+ apiConfig?: {
+ id: string;
+ autoRenew: boolean;
+ autoRenewDays?: number;
+ };
+};
+
+export interface TCertificateProfileMetrics {
+ profileId: string;
+ totalCertificates: number;
+ activeCertificates: number;
+ expiredCertificates: number;
+ expiringCertificates: number;
+ revokedCertificates: number;
+}
+
+export interface TCertificateProfileCertificate {
+ id: string;
+ serialNumber: string;
+ cn: string;
+ status: string;
+ notBefore: Date;
+ notAfter: Date;
+ isRevoked: boolean;
+ createdAt: Date;
+}
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
new file mode 100644
index 000000000..a80258264
--- /dev/null
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
@@ -0,0 +1,219 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2";
+import { DatabaseError } from "@app/lib/errors";
+import { ormify } from "@app/lib/knex";
+
+import {
+ TCertificateTemplateV2,
+ TCertificateTemplateV2Insert,
+ TCertificateTemplateV2Update
+} from "./certificate-template-v2-types";
+
+export type TCertificateTemplateV2DALFactory = ReturnType;
+
+export const certificateTemplateV2DALFactory = (db: TDbClient) => {
+ const certificateTemplateV2Orm = ormify(db, TableName.CertificateTemplateV2);
+
+ const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
+ const serialized = { ...data } as Record;
+ const jsonFields = [
+ "attributes",
+ "keyUsages",
+ "extendedKeyUsages",
+ "subjectAlternativeNames",
+ "validity",
+ "signatureAlgorithm",
+ "keyAlgorithm"
+ ];
+
+ jsonFields.forEach((field) => {
+ const value = (data as Record)[field];
+ if (value !== undefined) {
+ serialized[field] = JSON.stringify(value);
+ }
+ });
+
+ return serialized;
+ };
+
+ const parseJsonFields = (raw: Record): TCertificateTemplateV2 => {
+ const jsonFields = [
+ "attributes",
+ "keyUsages",
+ "extendedKeyUsages",
+ "subjectAlternativeNames",
+ "validity",
+ "signatureAlgorithm",
+ "keyAlgorithm"
+ ];
+ const parsed = { ...raw };
+
+ jsonFields.forEach((field) => {
+ const value = raw[field];
+ if (value) {
+ parsed[field] = typeof value === "string" ? JSON.parse(value) : value;
+ }
+ });
+
+ return parsed as TCertificateTemplateV2;
+ };
+
+ const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => {
+ try {
+ const serializedData = serializeJsonFields(data);
+ const [certificateTemplateV2] = await (tx || db)(TableName.CertificateTemplateV2)
+ .insert(serializedData as TCertificateTemplatesV2Insert)
+ .returning("*");
+
+ if (!certificateTemplateV2) {
+ throw new Error("Failed to create certificate template v2");
+ }
+
+ return parseJsonFields(certificateTemplateV2);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Create certificate template v2" });
+ }
+ };
+
+ const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => {
+ try {
+ const serializedData = serializeJsonFields(data);
+ const [certificateTemplateV2] = await (tx || db)(TableName.CertificateTemplateV2)
+ .where({ id })
+ .update(serializedData)
+ .returning("*");
+
+ if (!certificateTemplateV2) {
+ return null;
+ }
+
+ return parseJsonFields(certificateTemplateV2);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Update certificate template v2" });
+ }
+ };
+
+ const deleteById = async (id: string, tx?: Knex) => {
+ try {
+ const [certificateTemplateV2] = await (tx || db)(TableName.CertificateTemplateV2)
+ .where({ id })
+ .del()
+ .returning("*");
+
+ return certificateTemplateV2;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Delete certificate template v2" });
+ }
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2).where({ id }).first();
+
+ if (!certificateTemplateV2) {
+ return null;
+ }
+
+ return parseJsonFields(certificateTemplateV2);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate template v2 by id" });
+ }
+ };
+
+ const findByProjectId = async (
+ projectId: string,
+ options: {
+ offset?: number;
+ limit?: number;
+ search?: string;
+ } = {},
+ tx?: Knex
+ ) => {
+ try {
+ const { offset = 0, limit = 20, search } = options;
+
+ let query = (tx || db)(TableName.CertificateTemplateV2).where({ projectId });
+
+ if (search) {
+ query = query.where((builder) => {
+ void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
+ });
+ }
+
+ const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit);
+
+ return certificateTemplatesV2.map((template: Record) => parseJsonFields(template));
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" });
+ }
+ };
+
+ const countByProjectId = async (
+ projectId: string,
+ options: {
+ search?: string;
+ } = {},
+ tx?: Knex
+ ) => {
+ try {
+ const { search } = options;
+
+ let query = (tx || db)(TableName.CertificateTemplateV2).where({ projectId });
+
+ if (search) {
+ query = query.where((builder) => {
+ void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
+ });
+ }
+
+ const result = await query.count("*").first();
+ return parseInt((result as unknown as { count: string }).count || "0", 10);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" });
+ }
+ };
+
+ const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
+ try {
+ const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
+ .where({ name, projectId })
+ .first();
+
+ if (!certificateTemplateV2) {
+ return null;
+ }
+
+ return parseJsonFields(certificateTemplateV2);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" });
+ }
+ };
+
+ const isTemplateInUse = async (templateId: string, tx?: Knex) => {
+ try {
+ const profileCount = await (tx || db)(TableName.CertificateProfile)
+ .where({ certificateTemplateId: templateId })
+ .count("*")
+ .first();
+
+ return parseInt(profileCount || "0", 10) > 0;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" });
+ }
+ };
+
+ return {
+ ...certificateTemplateV2Orm,
+ create,
+ updateById,
+ deleteById,
+ findById,
+ findByProjectId,
+ countByProjectId,
+ findByNameAndProjectId,
+ isTemplateInUse
+ };
+};
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
new file mode 100644
index 000000000..e31e798eb
--- /dev/null
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts
@@ -0,0 +1,170 @@
+import { z } from "zod";
+
+const attributeTypeSchema = z.enum([
+ "common_name",
+ "organization_name",
+ "organization_unit",
+ "locality",
+ "state",
+ "country",
+ "email",
+ "street_address",
+ "postal_code"
+]);
+
+const includeTypeSchema = z.enum(["mandatory", "optional", "prohibit"]);
+
+const sanTypeSchema = z.enum(["dns_name", "ip_address", "email", "uri"]);
+
+const durationUnitSchema = z.enum(["days", "months", "years"]);
+
+export const templateV2AttributeSchema = z
+ .object({
+ type: attributeTypeSchema,
+ include: includeTypeSchema,
+ value: z.array(z.string()).optional()
+ })
+ .refine(
+ (data) => {
+ if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Mandatory attributes can only have one value or no value (empty)"
+ }
+ );
+
+export const templateV2KeyUsagesSchema = z.object({
+ requiredUsages: z.object({
+ all: z.array(z.string())
+ }),
+ optionalUsages: z.object({
+ all: z.array(z.string())
+ })
+});
+
+export const templateV2ExtendedKeyUsagesSchema = z.object({
+ requiredUsages: z.object({
+ all: z.array(z.string())
+ }),
+ optionalUsages: z.object({
+ all: z.array(z.string())
+ })
+});
+
+export const templateV2SanSchema = z
+ .object({
+ type: sanTypeSchema,
+ include: includeTypeSchema,
+ value: z.array(z.string()).optional()
+ })
+ .refine(
+ (data) => {
+ if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Mandatory SANs can only have one value or no value (empty)"
+ }
+ );
+
+export const templateV2ValiditySchema = z.object({
+ maxDuration: z.object({
+ value: z.number().positive(),
+ unit: durationUnitSchema
+ }),
+ minDuration: z
+ .object({
+ value: z.number().positive(),
+ unit: durationUnitSchema
+ })
+ .optional()
+});
+
+export const templateV2SignatureAlgorithmSchema = z.object({
+ allowedAlgorithms: z.array(z.string()).min(1),
+ defaultAlgorithm: z.string()
+});
+
+export const templateV2KeyAlgorithmSchema = z.object({
+ allowedKeyTypes: z.array(z.string()).min(1),
+ defaultKeyType: z.string()
+});
+
+export const createCertificateTemplateV2Schema = z.object({
+ projectId: z.string().min(1),
+ name: z.string().min(1).max(255),
+ description: z.string().max(1000).optional(),
+ attributes: z.array(templateV2AttributeSchema).optional(),
+ keyUsages: templateV2KeyUsagesSchema.optional(),
+ extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
+ subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
+ validity: templateV2ValiditySchema.optional(),
+ signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
+ keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
+});
+
+export const updateCertificateTemplateV2Schema = z.object({
+ name: z.string().min(1).max(255).optional(),
+ description: z.string().max(1000).optional(),
+ attributes: z.array(templateV2AttributeSchema).optional(),
+ keyUsages: templateV2KeyUsagesSchema.optional(),
+ extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
+ subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
+ validity: templateV2ValiditySchema.optional(),
+ signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
+ keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
+});
+
+export const getCertificateTemplateV2ByIdSchema = z.object({
+ id: z.string().uuid()
+});
+
+export const listCertificateTemplatesV2Schema = z.object({
+ projectId: z.string().min(1),
+ offset: z.coerce.number().min(0).default(0),
+ limit: z.coerce.number().min(1).max(100).default(20),
+ search: z.string().optional()
+});
+
+export const deleteCertificateTemplateV2Schema = z.object({
+ id: z.string().uuid()
+});
+
+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
+ .array(
+ z.object({
+ type: sanTypeSchema,
+ value: z.string()
+ })
+ )
+ .optional(),
+ validity: z
+ .object({
+ ttl: z.string()
+ })
+ .optional(),
+ signatureAlgorithm: z.string().optional(),
+ keyAlgorithm: z.string().optional()
+});
+
+export const validateCertificateRequestSchema = z.object({
+ templateId: z.string().uuid(),
+ request: certificateRequestSchema
+});
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
new file mode 100644
index 000000000..f4b93e5a2
--- /dev/null
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts
@@ -0,0 +1,1243 @@
+/* eslint-disable no-await-in-loop */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-return */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { ForbiddenError } from "@casl/ability";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+
+import { ActorType, AuthMethod } from "../auth/auth-type";
+import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
+import {
+ certificateTemplateV2ServiceFactory,
+ TCertificateTemplateV2ServiceFactory
+} from "./certificate-template-v2-service";
+import {
+ TCertificateRequest,
+ TCertificateTemplateV2,
+ TCertificateTemplateV2Insert,
+ TTemplateV2Policy
+} from "./certificate-template-v2-types";
+
+describe("CertificateTemplateV2Service", () => {
+ let service: TCertificateTemplateV2ServiceFactory;
+
+ const mockCertificateTemplateV2DAL = {
+ create: vi.fn(),
+ findById: vi.fn(),
+ updateById: vi.fn(),
+ deleteById: vi.fn(),
+ findByProjectId: vi.fn(),
+ countByProjectId: vi.fn(),
+ isTemplateInUse: vi.fn(),
+ findByNameAndProjectId: vi.fn(),
+ transaction: vi.fn(),
+ find: vi.fn(),
+ findOne: vi.fn(),
+ findMany: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ insertMany: vi.fn(),
+ batchInsert: vi.fn(),
+ upsert: vi.fn(),
+ countDocuments: vi.fn()
+ } as any;
+
+ const mockActor = {
+ actor: ActorType.USER,
+ actorId: "user-123",
+ actorAuthMethod: AuthMethod.EMAIL,
+ actorOrgId: "org-123"
+ };
+
+ const samplePolicy: TTemplateV2Policy = {
+ attributes: [
+ {
+ type: "common_name",
+ include: "mandatory",
+ value: ["example.com"]
+ },
+ {
+ type: "organization_name",
+ include: "optional"
+ },
+ {
+ type: "country",
+ include: "prohibit"
+ }
+ ],
+ keyUsages: {
+ requiredUsages: { all: ["digital_signature", "key_encipherment"] },
+ optionalUsages: { all: ["data_encipherment"] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: ["server_auth"] },
+ optionalUsages: { all: ["client_auth"] }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional",
+ value: ["example.com", "*.example.com"]
+ },
+ {
+ type: "ip_address",
+ include: "mandatory",
+ value: ["192.168.1.1"]
+ }
+ ],
+ validity: {
+ maxDuration: { value: 90, unit: "days" },
+ minDuration: { value: 1, unit: "days" }
+ },
+ signatureAlgorithm: {
+ allowedAlgorithms: ["RSA-SHA256", "ECDSA-SHA256"],
+ defaultAlgorithm: "RSA-SHA256"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA-2048", "RSA-4096", "ECDSA-P256"],
+ defaultKeyType: "RSA-2048"
+ }
+ };
+
+ const sampleTemplate: TCertificateTemplateV2 = {
+ id: "template-123",
+ projectId: "project-123",
+ name: "Web Server Template",
+ description: "Template for web server certificates",
+ ...samplePolicy,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ const mockPermission = {
+ can: vi.fn().mockReturnValue(true),
+ cannot: vi.fn().mockReturnValue(false),
+ relevantRuleFor: vi.fn().mockReturnValue(null),
+ rulesFor: vi.fn().mockReturnValue([]),
+ rules: [],
+ detectSubjectType: vi.fn().mockReturnValue("certificate-templates-v2"),
+ modelName: "certificate-templates-v2",
+ throwUnlessCan: vi.fn(),
+ unlessCan: vi.fn().mockReturnValue({ throwUnlessCan: vi.fn() })
+ };
+
+ const mockPermissionService = {
+ getProjectPermission: vi.fn().mockResolvedValue({
+ permission: mockPermission
+ })
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ vi.spyOn(ForbiddenError, "from").mockReturnValue({
+ throwUnlessCan: vi.fn()
+ } as any);
+
+ mockPermissionService.getProjectPermission.mockResolvedValue({
+ permission: mockPermission
+ });
+
+ mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null);
+
+ service = certificateTemplateV2ServiceFactory({
+ certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory,
+ permissionService: mockPermissionService
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ describe("createTemplateV2", () => {
+ const createData: Omit = {
+ name: "Test Template",
+ description: "Test description",
+ ...samplePolicy
+ };
+
+ it("should create template with valid policy", async () => {
+ mockCertificateTemplateV2DAL.create.mockResolvedValue(sampleTemplate);
+
+ const result = await service.createTemplateV2({
+ ...mockActor,
+ projectId: "project-123",
+ data: createData
+ });
+
+ expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({
+ ...createData,
+ projectId: "project-123"
+ });
+ expect(result).toEqual(sampleTemplate);
+ });
+
+ it("should throw error for invalid policy - missing attributes", async () => {
+ const invalidData = {
+ ...createData,
+ attributes: undefined as any
+ };
+
+ await expect(
+ service.createTemplateV2({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow("Template policy must include attributes array");
+ });
+
+ it("should throw error for invalid policy - missing key usages", async () => {
+ const invalidData = {
+ ...createData,
+ keyUsages: undefined as any
+ };
+
+ await expect(
+ service.createTemplateV2({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow("Template policy must include valid key usages configuration");
+ });
+
+ it("should throw error when default signature algorithm not in allowed list", async () => {
+ const invalidData = {
+ ...createData,
+ signatureAlgorithm: {
+ allowedAlgorithms: ["RSA-SHA256"],
+ defaultAlgorithm: "ECDSA-SHA256"
+ }
+ };
+
+ await expect(
+ service.createTemplateV2({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow("Default signature algorithm must be in allowed algorithms list");
+ });
+
+ it("should throw error when default key algorithm not in allowed list", async () => {
+ const invalidData = {
+ ...createData,
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA-2048"],
+ defaultKeyType: "RSA-4096"
+ }
+ };
+
+ await expect(
+ service.createTemplateV2({
+ ...mockActor,
+ projectId: "project-123",
+ data: invalidData
+ })
+ ).rejects.toThrow("Default key algorithm must be in allowed key types list");
+ });
+ });
+
+ describe("updateTemplateV2", () => {
+ it("should update template with valid data", async () => {
+ const updateData = { name: "Updated Template Name" };
+ const updatedTemplate = { ...sampleTemplate, ...updateData };
+
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+ mockCertificateTemplateV2DAL.updateById.mockResolvedValue(updatedTemplate);
+
+ const result = await service.updateTemplateV2({
+ ...mockActor,
+ templateId: "template-123",
+ data: updateData
+ });
+
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", updateData);
+ expect(result).toEqual(updatedTemplate);
+ });
+
+ it("should throw NotFoundError when template does not exist", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(null);
+
+ await expect(
+ service.updateTemplateV2({
+ ...mockActor,
+ templateId: "nonexistent-template",
+ data: { name: "Updated Name" }
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("should validate policy when updating policy fields", async () => {
+ const invalidPolicyUpdate = {
+ signatureAlgorithm: {
+ allowedAlgorithms: ["RSA-SHA256"],
+ defaultAlgorithm: "INVALID-ALGO"
+ }
+ };
+
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+
+ await expect(
+ service.updateTemplateV2({
+ ...mockActor,
+ templateId: "template-123",
+ data: invalidPolicyUpdate
+ })
+ ).rejects.toThrow("Default signature algorithm must be in allowed algorithms list");
+ });
+ });
+
+ describe("getTemplateV2ById", () => {
+ it("should return template when found", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+
+ const result = await service.getTemplateV2ById({
+ ...mockActor,
+ templateId: "template-123"
+ });
+
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ expect(result).toEqual(sampleTemplate);
+ });
+
+ it("should throw NotFoundError when template does not exist", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(null);
+
+ await expect(
+ service.getTemplateV2ById({
+ ...mockActor,
+ templateId: "nonexistent-template"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("listTemplatesV2", () => {
+ it("should return templates list with pagination", async () => {
+ const templates = [sampleTemplate];
+ const totalCount = 1;
+
+ mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates);
+ mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount);
+
+ const result = await service.listTemplatesV2({
+ ...mockActor,
+ projectId: "project-123",
+ offset: 0,
+ limit: 20
+ });
+
+ expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", {
+ offset: 0,
+ limit: 20,
+ search: undefined
+ });
+ expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", {
+ search: undefined
+ });
+ expect(result).toEqual({ templates, totalCount });
+ });
+
+ it("should handle search parameter", async () => {
+ const templates = [sampleTemplate];
+ const totalCount = 1;
+
+ mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates);
+ mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount);
+
+ await service.listTemplatesV2({
+ ...mockActor,
+ projectId: "project-123",
+ search: "web server"
+ });
+
+ expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", {
+ offset: 0,
+ limit: 20,
+ search: "web server"
+ });
+ expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", {
+ search: "web server"
+ });
+ });
+ });
+
+ describe("deleteTemplateV2", () => {
+ it("should delete template when not in use", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+ mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(false);
+ mockCertificateTemplateV2DAL.deleteById.mockResolvedValue(sampleTemplate);
+
+ const result = await service.deleteTemplateV2({
+ ...mockActor,
+ templateId: "template-123"
+ });
+
+ expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
+ expect(mockCertificateTemplateV2DAL.isTemplateInUse).toHaveBeenCalledWith("template-123");
+ expect(mockCertificateTemplateV2DAL.deleteById).toHaveBeenCalledWith("template-123");
+ expect(result).toEqual(sampleTemplate);
+ });
+
+ it("should throw NotFoundError when template does not exist", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(null);
+
+ await expect(
+ service.deleteTemplateV2({
+ ...mockActor,
+ templateId: "nonexistent-template"
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+
+ it("should throw ForbiddenRequestError when template is in use", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+ mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(true);
+
+ await expect(
+ service.deleteTemplateV2({
+ ...mockActor,
+ templateId: "template-123"
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+ expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("validateCertificateRequest", () => {
+ const validRequest: TCertificateRequest = {
+ commonName: "example.com",
+ organization: "Example Corp",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [
+ { type: "dns_name", value: "example.com" },
+ { type: "dns_name", value: "*.example.com" },
+ { type: "ip_address", value: "192.168.1.1" }
+ ],
+ validity: { ttl: "30d" },
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA-2048"
+ };
+
+ beforeEach(() => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
+ });
+
+ it("should validate valid certificate request", async () => {
+ const result = await service.validateCertificateRequest("template-123", validRequest);
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ expect(result.warnings).toHaveLength(0);
+ });
+
+ it("should throw NotFoundError when template does not exist", async () => {
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(null);
+
+ await expect(service.validateCertificateRequest("nonexistent-template", validRequest)).rejects.toThrow(
+ NotFoundError
+ );
+ });
+
+ it("should detect missing mandatory attributes", async () => {
+ const invalidRequest = { ...validRequest, commonName: undefined };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("common_name is mandatory but not provided in request");
+ });
+
+ 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" };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("common_name value 'forbidden.com' is not in allowed values list");
+ });
+
+ it("should detect missing required key usages", async () => {
+ const invalidRequest = { ...validRequest, keyUsages: ["digital_signature"] };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Missing required key usages: key_encipherment");
+ });
+
+ it("should detect invalid key usages", async () => {
+ const invalidRequest = {
+ ...validRequest,
+ keyUsages: ["digital_signature", "key_encipherment", "invalid_usage"]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Invalid key usages: invalid_usage");
+ });
+
+ it("should detect missing required extended key usages", async () => {
+ const invalidRequest = { ...validRequest, extendedKeyUsages: [] };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Missing required extended key usages: server_auth");
+ });
+
+ it("should detect invalid extended key usages", async () => {
+ const invalidRequest = {
+ ...validRequest,
+ extendedKeyUsages: ["server_auth", "invalid_eku"]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Invalid extended key usages: invalid_eku");
+ });
+
+ it("should detect missing mandatory SAN entries", async () => {
+ const invalidRequest = { ...validRequest, subjectAlternativeNames: [] };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("ip_address SAN is mandatory but not provided in request");
+ });
+
+ it("should validate SAN values against allowed list", async () => {
+ const invalidRequest: TCertificateRequest = {
+ ...validRequest,
+ subjectAlternativeNames: [
+ { type: "dns_name", value: "forbidden.com" },
+ { type: "ip_address", value: "192.168.1.1" }
+ ]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "dns_name SAN value 'forbidden.com' does not match allowed patterns: example.com, *.example.com"
+ );
+ });
+
+ it("should detect invalid signature algorithm", async () => {
+ const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Signature algorithm 'MD5-RSA' is not allowed by template policy");
+ });
+
+ it("should detect invalid key algorithm", async () => {
+ const invalidRequest = { ...validRequest, keyAlgorithm: "RSA-1024" };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Key algorithm 'RSA-1024' is not allowed by template policy");
+ });
+
+ it("should detect TTL exceeding maximum duration", async () => {
+ const invalidRequest = { ...validRequest, validity: { ttl: "180d" } };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration");
+ });
+
+ it("should detect TTL below minimum duration", async () => {
+ const templateWithMinDuration = {
+ ...sampleTemplate,
+ validity: {
+ maxDuration: { value: 90, unit: "days" as const },
+ minDuration: { value: 7, unit: "days" as const }
+ }
+ };
+
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMinDuration);
+
+ const invalidRequest = { ...validRequest, validity: { ttl: "1d" } };
+
+ const result = await service.validateCertificateRequest("template-123", invalidRequest);
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("Requested validity period is below minimum required duration");
+ });
+
+ it("should handle various TTL formats", async () => {
+ const testCases = [
+ { ttl: "24h", shouldBeValid: true },
+ { ttl: "30d", shouldBeValid: true },
+ { ttl: "90d", shouldBeValid: true },
+ { ttl: "3m", shouldBeValid: true },
+ { ttl: "1y", shouldBeValid: false },
+ { ttl: "invalid", shouldThrow: true }
+ ];
+
+ for (const testCase of testCases) {
+ const request = { ...validRequest, validity: { ttl: testCase.ttl } };
+
+ if (testCase.shouldThrow) {
+ await expect(service.validateCertificateRequest("template-123", request)).rejects.toThrow(
+ `Invalid TTL format: ${testCase.ttl}`
+ );
+ } else {
+ const result = await service.validateCertificateRequest("template-123", request);
+ expect(result.isValid).toBe(testCase.shouldBeValid);
+ }
+ }
+ });
+
+ it("should allow optional 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,
+ keyUsages: ["digital_signature", "key_encipherment", "data_encipherment"],
+ extendedKeyUsages: ["server_auth", "client_auth"]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithOptionalUsages);
+
+ expect(result.isValid).toBe(true);
+ });
+
+ it("should validate wildcard patterns in optional attributes", async () => {
+ const wildcardTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ type: "common_name",
+ include: "optional" as const,
+ value: ["*.example.com", "*.test.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate);
+
+ const requestWithWildcard = {
+ ...validRequest,
+ commonName: "api.example.com"
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithWildcard);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("should reject wildcard patterns that don't match", async () => {
+ const wildcardTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ type: "common_name",
+ include: "optional" as const,
+ value: ["*.example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate);
+
+ const requestWithNonMatchingWildcard = {
+ ...validRequest,
+ commonName: "api.notexample.com"
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard);
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain(
+ "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com"
+ );
+ });
+
+ it("should allow empty mandatory attributes when no value specified", async () => {
+ const emptyMandatoryTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ type: "common_name",
+ include: "mandatory" as const,
+ value: undefined
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyMandatoryTemplate);
+
+ const requestWithoutCommonName = {
+ ...validRequest,
+ commonName: undefined
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName);
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("common_name is mandatory but not provided in request");
+ });
+
+ it("should prevent certificates from including prohibited SANs", async () => {
+ const prohibitTemplate = {
+ ...sampleTemplate,
+ subjectAlternativeNames: [
+ {
+ type: "email" as const,
+ include: "prohibit" as const
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(prohibitTemplate);
+
+ const requestWithProhibitedSan = {
+ ...validRequest,
+ subjectAlternativeNames: [{ type: "email" as const, value: "test@example.com" }]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan);
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("email SAN is prohibited by template policy");
+ });
+
+ describe("comprehensive template validation scenarios", () => {
+ it("should handle template with minimal required fields only", async () => {
+ const minimalTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ type: "common_name",
+ include: "mandatory" as const
+ }
+ ],
+ keyUsages: {
+ requiredUsages: { all: ["digital_signature"] },
+ optionalUsages: { all: [] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: [] },
+ optionalUsages: { all: ["server_auth"] }
+ },
+ subjectAlternativeNames: [],
+ validity: {
+ maxDuration: { value: 30, unit: "days" as const }
+ },
+ signatureAlgorithm: undefined,
+ keyAlgorithm: undefined
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate);
+
+ const minimalRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature"],
+ validity: { ttl: "15d" }
+ };
+
+ const result = await service.validateCertificateRequest("template-123", minimalRequest);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("should handle template with all fields set to optional", async () => {
+ const optionalTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ type: "common_name",
+ include: "optional" as const
+ },
+ {
+ type: "organization_name",
+ include: "optional" as const
+ },
+ {
+ type: "locality",
+ include: "optional" as const
+ }
+ ],
+ keyUsages: {
+ requiredUsages: { all: [] },
+ optionalUsages: { all: ["digital_signature", "key_encipherment"] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: [] },
+ optionalUsages: { all: ["server_auth", "client_auth"] }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional" as const
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(optionalTemplate);
+
+ const emptyRequest = {
+ validity: { ttl: "30d" }
+ };
+
+ const result = await service.validateCertificateRequest("template-123", emptyRequest);
+ expect(result.isValid).toBe(true);
+ });
+
+ it("should handle template with all 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
+ }
+ ],
+ keyUsages: {
+ requiredUsages: { all: ["digital_signature"] },
+ optionalUsages: { all: [] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: ["server_auth"] },
+ optionalUsages: { all: [] }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "email",
+ include: "prohibit" as const
+ },
+ {
+ type: "uri",
+ include: "prohibit" as const
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(prohibitTemplate);
+
+ const requestWithProhibited = {
+ commonName: "example.com",
+ organization: "Test Org",
+ locality: "Test City",
+ country: "US",
+ keyUsages: ["digital_signature"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [
+ { type: "email" as const, value: "test@example.com" },
+ { type: "uri" as const, value: "https://example.com" }
+ ],
+ validity: { ttl: "30d" }
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithProhibited);
+ expect(result.isValid).toBe(false);
+ expect(result.errors).toContain("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");
+ });
+
+ it("should validate complex attribute value constraints", async () => {
+ const constrainedTemplate = {
+ ...sampleTemplate,
+ attributes: [
+ {
+ 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: [
+ {
+ type: "dns_name",
+ include: "optional" as const,
+ value: ["example.com", "*.example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(constrainedTemplate);
+
+ const validConstrainedRequest = {
+ commonName: "example.com",
+ organization: "Example Corp",
+ country: "US",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ validity: { ttl: "30d" }
+ };
+
+ const validResult = await service.validateCertificateRequest("template-123", validConstrainedRequest);
+ expect(validResult.isValid).toBe(true);
+
+ const invalidConstrainedRequest = {
+ commonName: "forbidden.com",
+ organization: "Forbidden Corp",
+ country: "FR",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ validity: { ttl: "30d" }
+ };
+
+ const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest);
+ expect(invalidResult.isValid).toBe(false);
+ expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list");
+ 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 () => {
+ const sanTemplate = {
+ ...sampleTemplate,
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "mandatory" as const,
+ value: ["example.com", "test.com"]
+ },
+ {
+ type: "ip_address",
+ include: "optional" as const,
+ value: ["192.168.1.1", "10.0.0.1"]
+ },
+ {
+ type: "email",
+ include: "mandatory" as const,
+ value: ["admin@example.com", "test@example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate);
+
+ const validSanRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [
+ { type: "dns_name" as const, value: "example.com" },
+ { type: "ip_address" as const, value: "192.168.1.1" },
+ { type: "email" as const, value: "admin@example.com" }
+ ],
+ validity: { ttl: "30d" }
+ };
+
+ const validResult = await service.validateCertificateRequest("template-123", validSanRequest);
+ expect(validResult.isValid).toBe(true);
+
+ const missingSanRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [{ type: "dns_name" as const, value: "example.com" }],
+ validity: { ttl: "30d" }
+ };
+
+ const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest);
+ expect(missingResult.isValid).toBe(false);
+ expect(missingResult.errors).toContain("email SAN is mandatory but not provided in request");
+ });
+
+ it("should validate key usage combinations thoroughly", async () => {
+ const keyUsageTemplate = {
+ ...sampleTemplate,
+ keyUsages: {
+ requiredUsages: { all: ["digital_signature", "key_encipherment"] },
+ optionalUsages: { all: ["data_encipherment", "key_agreement"] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: ["server_auth"] },
+ optionalUsages: { all: ["client_auth", "email_protection"] }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional" as const,
+ value: ["example.com", "*.example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(keyUsageTemplate);
+
+ const minimalUsageRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ validity: { ttl: "30d" }
+ };
+
+ const minimalResult = await service.validateCertificateRequest("template-123", minimalUsageRequest);
+ expect(minimalResult.isValid).toBe(true);
+
+ const extendedUsageRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment", "data_encipherment"],
+ extendedKeyUsages: ["server_auth", "client_auth"],
+ validity: { ttl: "30d" }
+ };
+
+ const extendedResult = await service.validateCertificateRequest("template-123", extendedUsageRequest);
+ expect(extendedResult.isValid).toBe(true);
+
+ const forbiddenUsageRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment", "crl_sign"],
+ extendedKeyUsages: ["server_auth"],
+ validity: { ttl: "30d" }
+ };
+
+ const forbiddenResult = await service.validateCertificateRequest("template-123", forbiddenUsageRequest);
+ expect(forbiddenResult.isValid).toBe(false);
+ expect(forbiddenResult.errors).toContain("Invalid key usages: crl_sign");
+ });
+
+ it("should validate algorithm constraints thoroughly", async () => {
+ const algorithmTemplate = {
+ ...sampleTemplate,
+ signatureAlgorithm: {
+ allowedAlgorithms: ["RSA-SHA256", "RSA-SHA512"],
+ defaultAlgorithm: "RSA-SHA256"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA-2048", "RSA-4096"],
+ defaultKeyType: "RSA-2048"
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional" as const,
+ value: ["example.com", "*.example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate);
+
+ const validAlgoRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ signatureAlgorithm: "RSA-SHA512",
+ keyAlgorithm: "RSA-4096",
+ validity: { ttl: "30d" }
+ };
+
+ const validResult = await service.validateCertificateRequest("template-123", validAlgoRequest);
+ expect(validResult.isValid).toBe(true);
+
+ const invalidSigRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ signatureAlgorithm: "ECDSA-SHA256",
+ keyAlgorithm: "RSA-2048",
+ validity: { ttl: "30d" }
+ };
+
+ const invalidSigResult = await service.validateCertificateRequest("template-123", invalidSigRequest);
+ expect(invalidSigResult.isValid).toBe(false);
+ expect(invalidSigResult.errors).toContain(
+ "Signature algorithm 'ECDSA-SHA256' is not allowed by template policy"
+ );
+
+ const invalidKeyRequest = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "ECDSA-P256",
+ validity: { ttl: "30d" }
+ };
+
+ const invalidKeyResult = await service.validateCertificateRequest("template-123", invalidKeyRequest);
+ expect(invalidKeyResult.isValid).toBe(false);
+ expect(invalidKeyResult.errors).toContain("Key algorithm 'ECDSA-P256' is not allowed by template policy");
+ });
+
+ it("should validate validity period edge cases", async () => {
+ const validityTemplate = {
+ ...sampleTemplate,
+ validity: {
+ maxDuration: { value: 365, unit: "days" as const },
+ minDuration: { value: 1, unit: "days" as const }
+ },
+ subjectAlternativeNames: [
+ {
+ type: "dns_name",
+ include: "optional" as const,
+ value: ["example.com", "*.example.com"]
+ }
+ ]
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(validityTemplate);
+
+ const testCases = [
+ { ttl: "1d", shouldBeValid: true, description: "minimum duration" },
+ { ttl: "365d", shouldBeValid: true, description: "maximum duration" },
+ { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" },
+ { ttl: "23h", shouldBeValid: false, description: "below minimum" },
+ { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" },
+ { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" },
+ { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" },
+ { ttl: "1y", shouldBeValid: true, description: "exactly 365 days in years" }
+ ];
+
+ for (const testCase of testCases) {
+ const request = {
+ commonName: "example.com",
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ validity: { ttl: testCase.ttl }
+ };
+
+ const result = await service.validateCertificateRequest("template-123", request);
+ expect(result.isValid).toBe(testCase.shouldBeValid);
+
+ if (!testCase.shouldBeValid) {
+ expect(result.errors.length).toBeGreaterThan(0);
+ }
+ }
+ });
+ });
+
+ describe("algorithm validation", () => {
+ it("should validate signature algorithm constraints", async () => {
+ const algorithmTemplate = {
+ ...sampleTemplate,
+ signatureAlgorithm: {
+ allowedAlgorithms: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"],
+ defaultAlgorithm: "RSA-SHA256"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA_2048", "RSA_4096", "ECDSA_P256"],
+ defaultKeyType: "RSA_2048"
+ }
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate);
+
+ const testCases = [
+ {
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA_2048",
+ shouldBeValid: true,
+ description: "allowed algorithms"
+ },
+ {
+ signatureAlgorithm: "RSA-SHA512",
+ keyAlgorithm: "RSA_4096",
+ shouldBeValid: true,
+ description: "different allowed algorithms"
+ },
+ {
+ signatureAlgorithm: "ECDSA-SHA256",
+ keyAlgorithm: "ECDSA_P256",
+ shouldBeValid: true,
+ description: "ECDSA algorithms"
+ },
+ {
+ signatureAlgorithm: "MD5-RSA",
+ keyAlgorithm: "RSA_2048",
+ shouldBeValid: false,
+ description: "disallowed signature algorithm"
+ },
+ {
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA_1024",
+ shouldBeValid: false,
+ description: "disallowed key algorithm"
+ },
+ {
+ signatureAlgorithm: undefined,
+ keyAlgorithm: undefined,
+ shouldBeValid: true,
+ description: "no algorithms specified (should use defaults)"
+ }
+ ];
+
+ for (const testCase of testCases) {
+ const request = {
+ commonName: "example.com",
+ validity: { ttl: "30d" },
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [{ type: "ip_address" as const, value: "192.168.1.1" }],
+ signatureAlgorithm: testCase.signatureAlgorithm,
+ keyAlgorithm: testCase.keyAlgorithm
+ };
+
+ const result = await service.validateCertificateRequest("template-123", request);
+
+ expect(result.isValid).toBe(testCase.shouldBeValid);
+
+ if (!testCase.shouldBeValid) {
+ expect(result.errors.length).toBeGreaterThan(0);
+ expect(result.errors.some((error) => error.includes("algorithm") || error.includes("Algorithm"))).toBe(
+ true
+ );
+ }
+ }
+ });
+
+ it("should validate when no algorithm constraints are defined", async () => {
+ const templateWithoutAlgorithms = {
+ ...sampleTemplate,
+ signatureAlgorithm: undefined,
+ keyAlgorithm: undefined
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms);
+
+ const request = {
+ commonName: "example.com",
+ validity: { ttl: "30d" },
+ keyUsages: ["digital_signature", "key_encipherment"],
+ extendedKeyUsages: ["server_auth"],
+ subjectAlternativeNames: [{ type: "ip_address" as const, value: "192.168.1.1" }],
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA_2048"
+ };
+
+ const result = await service.validateCertificateRequest("template-123", request);
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
new file mode 100644
index 000000000..2ede3053c
--- /dev/null
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts
@@ -0,0 +1,548 @@
+import { ForbiddenError } from "@casl/ability";
+import RE2 from "re2";
+
+import { ActionProjectType } from "@app/db/schemas";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import {
+ ProjectPermissionPkiTemplateActions,
+ ProjectPermissionSub
+} from "@app/ee/services/permission/project-permission";
+import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+
+import { ActorAuthMethod, ActorType } from "../auth/auth-type";
+import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
+import {
+ TCertificateRequest,
+ TCertificateTemplateV2,
+ TCertificateTemplateV2Insert,
+ TCertificateTemplateV2Update,
+ TTemplateV2Policy,
+ TTemplateValidationResult
+} from "./certificate-template-v2-types";
+
+type TCertificateTemplateV2ServiceFactoryDep = {
+ certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
+ permissionService: Pick;
+};
+
+export type TCertificateTemplateV2ServiceFactory = ReturnType;
+
+export const certificateTemplateV2ServiceFactory = ({
+ certificateTemplateV2DAL,
+ permissionService
+}: TCertificateTemplateV2ServiceFactoryDep) => {
+ const parseTTL = (ttl: string): number => {
+ const regex = new RE2("^(\\d+)([dmyh])$");
+ const match = regex.exec(ttl);
+ if (!match) {
+ throw new Error(`Invalid TTL format: ${ttl}`);
+ }
+
+ const value = parseInt(match[1], 10);
+ const unit = match[2];
+
+ switch (unit) {
+ case "h":
+ return value * 60 * 60 * 1000;
+ case "d":
+ return value * 24 * 60 * 60 * 1000;
+ case "m":
+ return value * 30 * 24 * 60 * 60 * 1000;
+ case "y":
+ return value * 365 * 24 * 60 * 60 * 1000;
+ default:
+ throw new Error(`Unsupported TTL unit: ${unit}`);
+ }
+ };
+
+ const convertToMilliseconds = (value: number, unit: "days" | "months" | "years"): number => {
+ switch (unit) {
+ case "days":
+ return value * 24 * 60 * 60 * 1000;
+ case "months":
+ return value * 30 * 24 * 60 * 60 * 1000;
+ case "years":
+ return value * 365 * 24 * 60 * 60 * 1000;
+ default:
+ throw new Error(`Unsupported duration unit: ${unit as string}`);
+ }
+ };
+
+ const getRequestAttributeValue = (request: TCertificateRequest, attrType: string): string | undefined => {
+ 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;
+ }
+ };
+
+ const validateTemplatePolicy = (policy: Partial): void => {
+ if (!policy) {
+ throw new Error("Template policy is required");
+ }
+
+ if (!policy.attributes || policy.attributes.length === 0) {
+ throw new Error("Template policy must include attributes array");
+ }
+
+ if (!policy.keyUsages || !policy.keyUsages.requiredUsages || !policy.keyUsages.optionalUsages) {
+ throw new Error("Template policy must include valid key usages configuration");
+ }
+
+ if (policy.signatureAlgorithm) {
+ if (!policy.signatureAlgorithm.allowedAlgorithms.includes(policy.signatureAlgorithm.defaultAlgorithm)) {
+ throw new Error("Default signature algorithm must be in allowed algorithms list");
+ }
+ }
+
+ if (policy.keyAlgorithm) {
+ if (!policy.keyAlgorithm.allowedKeyTypes.includes(policy.keyAlgorithm.defaultKeyType)) {
+ throw new Error("Default key algorithm must be in allowed key types list");
+ }
+ }
+ };
+
+ const hasAnyPolicyField = (data: TCertificateTemplateV2Update): boolean => {
+ return !!(
+ data.attributes ||
+ data.keyUsages ||
+ data.extendedKeyUsages ||
+ data.subjectAlternativeNames ||
+ data.validity ||
+ data.signatureAlgorithm ||
+ data.keyAlgorithm
+ );
+ };
+
+ const validateRequestAgainstPolicy = (
+ template: TCertificateTemplateV2,
+ request: TCertificateRequest
+ ): TTemplateValidationResult => {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ template.attributes?.forEach((attrPolicy) => {
+ const requestValue = getRequestAttributeValue(request, attrPolicy.type);
+
+ if (attrPolicy.include === "mandatory") {
+ if (!requestValue) {
+ errors.push(`${attrPolicy.type} is mandatory but not provided in request`);
+ } else if (attrPolicy.value && attrPolicy.value.length > 0) {
+ if (!attrPolicy.value.includes(requestValue)) {
+ errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
+ }
+ }
+ }
+
+ if (attrPolicy.include === "prohibit" && requestValue) {
+ errors.push(`${attrPolicy.type} is prohibited by template policy`);
+ }
+
+ if (attrPolicy.include === "optional" && requestValue && attrPolicy.value && attrPolicy.value.length > 0) {
+ const isValidValue = attrPolicy.value.some((allowedValue) => {
+ if (allowedValue.includes("*")) {
+ const pattern = allowedValue.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 (template.keyUsages) {
+ const missingRequired = template.keyUsages.requiredUsages.all.filter(
+ (usage) => !request.keyUsages?.includes(usage)
+ );
+ if (missingRequired.length > 0) {
+ errors.push(`Missing required key usages: ${missingRequired.join(", ")}`);
+ }
+
+ if (request.keyUsages) {
+ const allAllowedUsages = [...template.keyUsages.requiredUsages.all, ...template.keyUsages.optionalUsages.all];
+ const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
+ if (invalidUsages.length > 0) {
+ errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`);
+ }
+ }
+ }
+
+ if (template.extendedKeyUsages) {
+ const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter(
+ (usage) => !request.extendedKeyUsages?.includes(usage)
+ );
+ if (missingRequired.length > 0) {
+ errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`);
+ }
+
+ if (request.extendedKeyUsages) {
+ const allAllowedUsages = [
+ ...template.extendedKeyUsages.requiredUsages.all,
+ ...template.extendedKeyUsages.optionalUsages.all
+ ];
+ const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage));
+ if (invalidUsages.length > 0) {
+ errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`);
+ }
+ }
+ }
+
+ template.subjectAlternativeNames?.forEach((sanPolicy) => {
+ const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanPolicy.type) || [];
+
+ if (sanPolicy.include === "mandatory") {
+ if (requestSans.length === 0) {
+ errors.push(`${sanPolicy.type} SAN is mandatory but not provided in request`);
+ } else if (sanPolicy.value && sanPolicy.value.length > 0) {
+ requestSans.forEach((san) => {
+ if (!sanPolicy.value!.includes(san.value)) {
+ errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
+ }
+ });
+ }
+ }
+
+ if (sanPolicy.include === "prohibit" && requestSans.length > 0) {
+ errors.push(`${sanPolicy.type} SAN is prohibited by template policy`);
+ }
+
+ if (sanPolicy.include === "optional" && sanPolicy.value && sanPolicy.value.length > 0) {
+ requestSans.forEach((san) => {
+ const isValidValue = sanPolicy.value!.some((allowedValue) => {
+ if (allowedValue.includes("*")) {
+ const pattern = allowedValue.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 (request.signatureAlgorithm && template.signatureAlgorithm) {
+ if (!template.signatureAlgorithm?.allowedAlgorithms.includes(request.signatureAlgorithm)) {
+ errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
+ }
+ }
+
+ if (request.keyAlgorithm && template.keyAlgorithm) {
+ if (!template.keyAlgorithm?.allowedKeyTypes.includes(request.keyAlgorithm)) {
+ errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`);
+ }
+ }
+
+ if (request.validity?.ttl && template.validity) {
+ const requestDuration = parseTTL(request.validity.ttl);
+ const maxDuration = convertToMilliseconds(
+ template.validity.maxDuration.value,
+ template.validity.maxDuration.unit
+ );
+
+ if (requestDuration > maxDuration) {
+ errors.push(`Requested validity period exceeds maximum allowed duration`);
+ }
+
+ if (template.validity.minDuration) {
+ const minDuration = convertToMilliseconds(
+ template.validity.minDuration.value,
+ template.validity.minDuration.unit
+ );
+ if (requestDuration < minDuration) {
+ errors.push(`Requested validity period is below minimum required duration`);
+ }
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings
+ };
+ };
+
+ const createTemplateV2 = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ data
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ data: Omit;
+ }): Promise => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Create,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ if (!data) {
+ throw new Error("Template data is required");
+ }
+
+ validateTemplatePolicy({
+ attributes: data.attributes,
+ keyUsages: data.keyUsages,
+ extendedKeyUsages: data.extendedKeyUsages,
+ subjectAlternativeNames: data.subjectAlternativeNames,
+ validity: data.validity,
+ signatureAlgorithm: data.signatureAlgorithm,
+ keyAlgorithm: data.keyAlgorithm
+ });
+
+ const template = await certificateTemplateV2DAL.create({
+ ...data,
+ projectId
+ });
+
+ return template;
+ };
+
+ const updateTemplateV2 = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ templateId,
+ data
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ templateId: string;
+ data: TCertificateTemplateV2Update;
+ }): Promise => {
+ const existingTemplate = await certificateTemplateV2DAL.findById(templateId);
+ if (!existingTemplate) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: existingTemplate.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Edit,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ if (hasAnyPolicyField(data)) {
+ const mergedPolicy = {
+ attributes: data.attributes || existingTemplate.attributes,
+ keyUsages: data.keyUsages || existingTemplate.keyUsages,
+ extendedKeyUsages: data.extendedKeyUsages || existingTemplate.extendedKeyUsages,
+ subjectAlternativeNames: data.subjectAlternativeNames || existingTemplate.subjectAlternativeNames,
+ validity: data.validity || existingTemplate.validity,
+ signatureAlgorithm: data.signatureAlgorithm || existingTemplate.signatureAlgorithm,
+ keyAlgorithm: data.keyAlgorithm || existingTemplate.keyAlgorithm
+ };
+
+ validateTemplatePolicy(mergedPolicy);
+ }
+
+ const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, data);
+ if (!updatedTemplate) {
+ throw new NotFoundError({ message: "Failed to update certificate template" });
+ }
+ return updatedTemplate;
+ };
+
+ const getTemplateV2ById = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ templateId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ templateId: string;
+ }): Promise => {
+ const template = await certificateTemplateV2DAL.findById(templateId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: template.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Read,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ return template;
+ };
+
+ const listTemplatesV2 = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ offset = 0,
+ limit = 20,
+ search
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ offset?: number;
+ limit?: number;
+ search?: string;
+ }): Promise<{
+ templates: TCertificateTemplateV2[];
+ totalCount: number;
+ }> => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Read,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ const templates = await certificateTemplateV2DAL.findByProjectId(projectId, {
+ offset,
+ limit,
+ search
+ });
+
+ const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search });
+
+ return {
+ templates,
+ totalCount
+ };
+ };
+
+ const deleteTemplateV2 = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ templateId
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ templateId: string;
+ }): Promise => {
+ const template = await certificateTemplateV2DAL.findById(templateId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: template.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Delete,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId);
+ if (isInUse) {
+ throw new ForbiddenRequestError({
+ message: "Cannot delete template that is in use by certificate profiles"
+ });
+ }
+
+ const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId);
+ if (!deletedTemplate) {
+ throw new NotFoundError({ message: "Failed to delete certificate template" });
+ }
+ return deletedTemplate as TCertificateTemplateV2;
+ };
+
+ const validateCertificateRequest = async (
+ templateId: string,
+ request: TCertificateRequest
+ ): Promise => {
+ const template = await certificateTemplateV2DAL.findById(templateId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+
+ return validateRequestAgainstPolicy(template, request);
+ };
+
+ return {
+ createTemplateV2,
+ updateTemplateV2,
+ getTemplateV2ById,
+ listTemplatesV2,
+ deleteTemplateV2,
+ validateCertificateRequest
+ };
+};
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
new file mode 100644
index 000000000..5cc947d90
--- /dev/null
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts
@@ -0,0 +1,125 @@
+import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2";
+
+export interface TTemplateV2Policy {
+ 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 TCertificateTemplateV2 = Omit<
+ TCertificateTemplatesV2,
+ | "attributes"
+ | "keyUsages"
+ | "extendedKeyUsages"
+ | "subjectAlternativeNames"
+ | "validity"
+ | "signatureAlgorithm"
+ | "keyAlgorithm"
+> & {
+ attributes: TTemplateV2Policy["attributes"];
+ keyUsages: TTemplateV2Policy["keyUsages"];
+ extendedKeyUsages: TTemplateV2Policy["extendedKeyUsages"];
+ subjectAlternativeNames: TTemplateV2Policy["subjectAlternativeNames"];
+ validity: TTemplateV2Policy["validity"];
+ signatureAlgorithm: TTemplateV2Policy["signatureAlgorithm"];
+ keyAlgorithm: TTemplateV2Policy["keyAlgorithm"];
+};
+
+export type TCertificateTemplateV2Insert = Omit<
+ TCertificateTemplatesV2Insert,
+ | "attributes"
+ | "keyUsages"
+ | "extendedKeyUsages"
+ | "subjectAlternativeNames"
+ | "validity"
+ | "signatureAlgorithm"
+ | "keyAlgorithm"
+> & {
+ attributes?: TTemplateV2Policy["attributes"];
+ keyUsages?: TTemplateV2Policy["keyUsages"];
+ extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
+ subjectAlternativeNames?: TTemplateV2Policy["subjectAlternativeNames"];
+ validity?: TTemplateV2Policy["validity"];
+ signatureAlgorithm?: TTemplateV2Policy["signatureAlgorithm"];
+ keyAlgorithm?: TTemplateV2Policy["keyAlgorithm"];
+};
+
+export type TCertificateTemplateV2Update = Partial<
+ Pick<
+ TCertificateTemplateV2,
+ | "name"
+ | "description"
+ | "attributes"
+ | "keyUsages"
+ | "extendedKeyUsages"
+ | "subjectAlternativeNames"
+ | "validity"
+ | "signatureAlgorithm"
+ | "keyAlgorithm"
+ >
+>;
+
+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<{
+ type: "dns_name" | "ip_address" | "email" | "uri";
+ value: string;
+ }>;
+ validity?: {
+ ttl: string;
+ };
+ signatureAlgorithm?: string;
+ keyAlgorithm?: string;
+}
+
+export interface TTemplateValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+}
diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
new file mode 100644
index 000000000..22eb3d8ca
--- /dev/null
+++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
@@ -0,0 +1,372 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-call */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+import { ForbiddenError } from "@casl/ability";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
+import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
+
+import { ActorType, AuthMethod } from "../auth/auth-type";
+import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service";
+
+describe("CertificateV3Service", () => {
+ let service: TCertificateV3ServiceFactory;
+
+ const mockCertificateDAL = {
+ findOne: vi.fn(),
+ updateById: vi.fn()
+ } as any;
+
+ const mockCertificateAuthorityDAL = {
+ findByIdWithAssociatedCa: vi.fn()
+ } as any;
+
+ const mockCertificateProfileDAL = {
+ findByIdWithConfigs: vi.fn()
+ } as any;
+
+ const mockCertificateTemplateV2Service = {
+ validateCertificateRequest: vi.fn(),
+ getTemplateV2ById: vi.fn()
+ } as any;
+
+ const mockInternalCaService = {
+ signCertFromCa: vi.fn(),
+ issueCertFromCa: vi.fn()
+ } as any;
+
+ const mockPermissionService = {
+ getProjectPermission: vi.fn().mockResolvedValue({
+ permission: {
+ throwUnlessCan: vi.fn()
+ }
+ })
+ } as any;
+
+ const mockActor = {
+ actor: ActorType.USER,
+ actorId: "user-123",
+ actorAuthMethod: AuthMethod.EMAIL as any,
+ actorOrgId: "org-123"
+ };
+
+ beforeEach(() => {
+ vi.spyOn(ForbiddenError, "from").mockReturnValue({
+ throwUnlessCan: vi.fn()
+ } as any);
+
+ service = certificateV3ServiceFactory({
+ certificateDAL: mockCertificateDAL,
+ certificateAuthorityDAL: mockCertificateAuthorityDAL,
+ certificateProfileDAL: mockCertificateProfileDAL,
+ certificateTemplateV2Service: mockCertificateTemplateV2Service,
+ internalCaService: mockInternalCaService,
+ permissionService: mockPermissionService
+ });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("issueCertificateFromProfile", () => {
+ const mockCertificateRequest = {
+ commonName: "test.example.com",
+ organization: "Test Org",
+ keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE],
+ extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH],
+ validity: { ttl: "30d" },
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA_2048"
+ };
+
+ it("should issue certificate successfully for API enrollment profile", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ const mockCA = {
+ id: "ca-123",
+ externalCa: null
+ };
+
+ const mockTemplate = {
+ id: "template-123",
+ signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
+ keyAlgorithm: { defaultKeyType: "RSA_2048" },
+ attributes: []
+ };
+
+ const mockCertificateResult = {
+ certificate: Buffer.from("cert"),
+ certificateChain: Buffer.from("chain"),
+ privateKey: Buffer.from("key"),
+ serialNumber: "123456"
+ };
+
+ const mockCertRecord = {
+ id: "cert-123",
+ serialNumber: "123456"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+ mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
+ isValid: true,
+ errors: [],
+ warnings: []
+ });
+ mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(mockCA);
+ mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(mockTemplate);
+ mockInternalCaService.issueCertFromCa.mockResolvedValue(mockCertificateResult);
+ mockCertificateDAL.findOne.mockResolvedValue(mockCertRecord);
+ mockCertificateDAL.updateById.mockResolvedValue({});
+
+ const result = await service.issueCertificateFromProfile({
+ profileId,
+ certificateRequest: mockCertificateRequest,
+ ...mockActor
+ });
+
+ expect(result).toHaveProperty("certificate");
+ expect(result).toHaveProperty("privateKey");
+ expect(result).toHaveProperty("serialNumber", "123456");
+ expect(result).toHaveProperty("certificateId", "cert-123");
+ });
+
+ it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.EST, // Wrong enrollment type
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+
+ await expect(
+ service.issueCertificateFromProfile({
+ profileId,
+ certificateRequest: mockCertificateRequest,
+ ...mockActor
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+
+ await expect(
+ service.issueCertificateFromProfile({
+ profileId,
+ certificateRequest: mockCertificateRequest,
+ ...mockActor
+ })
+ ).rejects.toThrow("Profile is not configured for api enrollment");
+ });
+
+ it("should throw NotFoundError when profile doesn't exist", async () => {
+ const profileId = "non-existent-profile";
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null);
+
+ await expect(
+ service.issueCertificateFromProfile({
+ profileId,
+ certificateRequest: mockCertificateRequest,
+ ...mockActor
+ })
+ ).rejects.toThrow(NotFoundError);
+ });
+ });
+
+ describe("signCertificateFromProfile", () => {
+ const mockCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC...";
+ const mockValidity = { ttl: "30d" };
+
+ it("should sign certificate successfully for API enrollment profile", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ const mockCA = {
+ id: "ca-123",
+ externalCa: null
+ };
+
+ const mockSignResult = {
+ certificate: Buffer.from("signed-cert"),
+ certificateChain: Buffer.from("chain"),
+ serialNumber: "789012"
+ };
+
+ const mockCertRecord = {
+ id: "cert-456",
+ serialNumber: "789012"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+ mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(mockCA);
+ mockInternalCaService.signCertFromCa.mockResolvedValue(mockSignResult);
+ mockCertificateDAL.findOne.mockResolvedValue(mockCertRecord);
+ mockCertificateDAL.updateById.mockResolvedValue({});
+
+ const result = await service.signCertificateFromProfile({
+ profileId,
+ csr: mockCSR,
+ validity: mockValidity,
+ ...mockActor
+ });
+
+ expect(result).toHaveProperty("certificate");
+ expect(result).toHaveProperty("serialNumber", "789012");
+ expect(result).toHaveProperty("certificateId", "cert-456");
+ expect(result).not.toHaveProperty("privateKey");
+ });
+
+ it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.EST, // Wrong enrollment type
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+
+ await expect(
+ service.signCertificateFromProfile({
+ profileId,
+ csr: mockCSR,
+ validity: mockValidity,
+ ...mockActor
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+
+ await expect(
+ service.signCertificateFromProfile({
+ profileId,
+ csr: mockCSR,
+ validity: mockValidity,
+ ...mockActor
+ })
+ ).rejects.toThrow("Profile is not configured for api enrollment");
+ });
+ });
+
+ describe("orderCertificateFromProfile", () => {
+ const mockCertificateOrder = {
+ identifiers: [{ type: "dns" as const, value: "example.com" }],
+ validity: { ttl: "30d" },
+ commonName: "example.com",
+ keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE],
+ extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH],
+ signatureAlgorithm: "RSA-SHA256",
+ keyAlgorithm: "RSA_2048"
+ };
+
+ it("should create order successfully for API enrollment profile", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ const mockCA = {
+ id: "ca-123",
+ externalCa: null
+ };
+
+ const mockTemplate = {
+ id: "template-123",
+ signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
+ keyAlgorithm: { defaultKeyType: "RSA_2048" },
+ attributes: []
+ };
+
+ const mockCertificateResult = {
+ certificate: Buffer.from("cert"),
+ certificateChain: Buffer.from("chain"),
+ privateKey: Buffer.from("key"),
+ serialNumber: "123456"
+ };
+
+ const mockCertRecord = {
+ id: "cert-123",
+ serialNumber: "123456"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+ mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
+ isValid: true,
+ errors: [],
+ warnings: []
+ });
+ mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(mockCA);
+ mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(mockTemplate);
+ mockInternalCaService.issueCertFromCa.mockResolvedValue(mockCertificateResult);
+ mockCertificateDAL.findOne.mockResolvedValue(mockCertRecord);
+ mockCertificateDAL.updateById.mockResolvedValue({});
+
+ const result = await service.orderCertificateFromProfile({
+ profileId,
+ certificateOrder: mockCertificateOrder,
+ ...mockActor
+ });
+
+ expect(result).toHaveProperty("orderId");
+ expect(result).toHaveProperty("status", "valid");
+ expect(result).toHaveProperty("certificate");
+ expect(result.identifiers).toHaveLength(1);
+ expect(result.identifiers[0]).toEqual({
+ type: "dns",
+ value: "example.com",
+ status: "valid"
+ });
+ });
+
+ it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.EST, // Wrong enrollment type
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+
+ await expect(
+ service.orderCertificateFromProfile({
+ profileId,
+ certificateOrder: mockCertificateOrder,
+ ...mockActor
+ })
+ ).rejects.toThrow(ForbiddenRequestError);
+
+ await expect(
+ service.orderCertificateFromProfile({
+ profileId,
+ certificateOrder: mockCertificateOrder,
+ ...mockActor
+ })
+ ).rejects.toThrow("Profile is not configured for api enrollment");
+ });
+ });
+});
diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts
new file mode 100644
index 000000000..6fb16aa14
--- /dev/null
+++ b/backend/src/services/certificate-v3/certificate-v3-service.ts
@@ -0,0 +1,395 @@
+import { ForbiddenError } from "@casl/ability";
+import { randomUUID } from "crypto";
+
+import { ActionProjectType } from "@app/db/schemas";
+import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
+import {
+ ProjectPermissionCertificateProfileActions,
+ ProjectPermissionSub
+} from "@app/ee/services/permission/project-permission";
+import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
+import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
+import {
+ TCertificateAuthorityDALFactory,
+ TCertificateAuthorityWithAssociatedCa
+} from "@app/services/certificate-authority/certificate-authority-dal";
+import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
+import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
+import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
+import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
+import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
+
+import {
+ bufferToString,
+ buildCertificateSubjectFromTemplate,
+ buildSubjectAlternativeNamesFromTemplate,
+ mapEnumsForValidation,
+ normalizeDateForApi
+} from "../certificate-common/certificate-utils";
+import {
+ TCertificateFromProfileResponse,
+ TCertificateOrderResponse,
+ TIssueCertificateFromProfileDTO,
+ TOrderCertificateFromProfileDTO,
+ TSignCertificateFromProfileDTO
+} from "./certificate-v3-types";
+
+type TCertificateV3ServiceFactoryDep = {
+ certificateDAL: Pick;
+ certificateAuthorityDAL: Pick;
+ certificateProfileDAL: Pick;
+ certificateTemplateV2Service: Pick<
+ TCertificateTemplateV2ServiceFactory,
+ "validateCertificateRequest" | "getTemplateV2ById"
+ >;
+ internalCaService: Pick;
+ permissionService: Pick;
+};
+
+export type TCertificateV3ServiceFactory = ReturnType;
+
+const validateProfileAndPermissions = async (
+ profileId: string,
+ actor: ActorType,
+ actorId: string,
+ actorAuthMethod: ActorAuthMethod,
+ actorOrgId: string,
+ certificateProfileDAL: Pick,
+ permissionService: Pick,
+ requiredEnrollmentType: EnrollmentType
+) => {
+ const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
+ if (!profile) {
+ throw new NotFoundError({ message: "Certificate profile not found" });
+ }
+
+ if (profile.enrollmentType !== requiredEnrollmentType) {
+ throw new ForbiddenRequestError({
+ message: `Profile is not configured for ${requiredEnrollmentType} enrollment`
+ });
+ }
+
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId: profile.projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionCertificateProfileActions.IssueCert,
+ ProjectPermissionSub.CertificateProfiles
+ );
+
+ return profile;
+};
+
+const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
+ const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
+ if (caType !== CaType.INTERNAL) {
+ throw new BadRequestError({ message: `Only internal CAs support ${operation}` });
+ }
+ return caType;
+};
+
+const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => {
+ if (typeof certData === "string") return certData;
+ if (Buffer.isBuffer(certData)) return bufferToString(certData);
+ if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) {
+ return bufferToString(certData.rawData);
+ }
+ return bufferToString(certData as unknown as Buffer);
+};
+
+export const certificateV3ServiceFactory = ({
+ certificateDAL,
+ certificateAuthorityDAL,
+ certificateProfileDAL,
+ certificateTemplateV2Service,
+ internalCaService,
+ permissionService
+}: TCertificateV3ServiceFactoryDep) => {
+ const issueCertificateFromProfile = async ({
+ profileId,
+ certificateRequest,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TIssueCertificateFromProfileDTO): Promise => {
+ const profile = await validateProfileAndPermissions(
+ profileId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ certificateProfileDAL,
+ permissionService,
+ EnrollmentType.API
+ );
+
+ const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
+ const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
+ profile.certificateTemplateId,
+ mappedCertificateRequest
+ );
+
+ if (!validationResult.isValid) {
+ throw new BadRequestError({
+ message: `Certificate request validation failed: ${validationResult.errors.join(", ")}`
+ });
+ }
+
+ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
+ if (!ca) {
+ throw new NotFoundError({ message: "Certificate Authority not found" });
+ }
+
+ validateCaSupport(ca, "direct certificate issuance");
+
+ if (!actorAuthMethod) {
+ throw new BadRequestError({ message: "Authentication method is required for certificate issuance" });
+ }
+
+ const template = await certificateTemplateV2Service.getTemplateV2ById({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ templateId: profile.certificateTemplateId
+ });
+
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found for this profile" });
+ }
+
+ const effectiveSignatureAlgorithm =
+ certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
+ const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
+
+ const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes);
+ const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
+ certificateRequest,
+ template.subjectAlternativeNames
+ );
+
+ const { certificate, certificateChain, privateKey, serialNumber } = await internalCaService.issueCertFromCa({
+ caId: ca.id,
+ friendlyName: certificateSubject.common_name || "Certificate",
+ commonName: certificateSubject.common_name || "",
+ altNames: subjectAlternativeNames,
+ ttl: certificateRequest.validity.ttl,
+ keyUsages: certificateRequest.keyUsages,
+ extendedKeyUsages: certificateRequest.extendedKeyUsages,
+ notBefore: normalizeDateForApi(certificateRequest.notBefore),
+ notAfter: normalizeDateForApi(certificateRequest.notAfter),
+ signatureAlgorithm: effectiveSignatureAlgorithm,
+ keyAlgorithm: effectiveKeyAlgorithm,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ });
+
+ const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
+ if (!cert) {
+ throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
+ }
+
+ await certificateDAL.updateById(cert.id, { profileId });
+
+ const certificateChainString = bufferToString(certificateChain);
+ return {
+ certificate: bufferToString(certificate),
+ issuingCaCertificate: certificateChainString.split("\n").pop() || bufferToString(certificate),
+ certificateChain: certificateChainString,
+ privateKey: bufferToString(privateKey),
+ serialNumber,
+ certificateId: cert.id
+ };
+ };
+
+ const signCertificateFromProfile = async ({
+ profileId,
+ csr,
+ validity,
+ notBefore,
+ notAfter,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TSignCertificateFromProfileDTO): Promise> => {
+ const profile = await validateProfileAndPermissions(
+ profileId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ certificateProfileDAL,
+ permissionService,
+ EnrollmentType.API
+ );
+
+ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
+ if (!ca) {
+ throw new NotFoundError({ message: "Certificate Authority not found" });
+ }
+
+ validateCaSupport(ca, "CSR signing");
+
+ const { certificate, certificateChain, serialNumber } = await internalCaService.signCertFromCa({
+ isInternal: true,
+ caId: ca.id,
+ csr,
+ ttl: validity.ttl,
+ altNames: "",
+ notBefore: normalizeDateForApi(notBefore),
+ notAfter: normalizeDateForApi(notAfter)
+ });
+
+ const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
+ if (!cert) {
+ throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
+ }
+
+ await certificateDAL.updateById(cert.id, { profileId });
+
+ const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
+ const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
+
+ return {
+ certificate: certificateString,
+ issuingCaCertificate: certificateChainString.split("\n").pop() || certificateString,
+ certificateChain: certificateChainString,
+ serialNumber,
+ certificateId: cert.id
+ };
+ };
+
+ const orderCertificateFromProfile = async ({
+ profileId,
+ certificateOrder,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ }: TOrderCertificateFromProfileDTO): Promise => {
+ const profile = await validateProfileAndPermissions(
+ profileId,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ certificateProfileDAL,
+ permissionService,
+ EnrollmentType.API
+ );
+
+ const certificateRequest = {
+ commonName: certificateOrder.commonName,
+ 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) => ({
+ type: id.type === "dns" ? ("dns_name" as const) : ("ip_address" as const),
+ value: id.value
+ })),
+ validity: certificateOrder.validity,
+ notBefore: certificateOrder.notBefore,
+ notAfter: certificateOrder.notAfter,
+ signatureAlgorithm: certificateOrder.signatureAlgorithm,
+ keyAlgorithm: certificateOrder.keyAlgorithm
+ };
+
+ const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
+ const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
+ profile.certificateTemplateId,
+ mappedCertificateRequest
+ );
+
+ if (!validationResult.isValid) {
+ throw new BadRequestError({
+ message: `Certificate order validation failed: ${validationResult.errors.join(", ")}`
+ });
+ }
+
+ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
+ if (!ca) {
+ throw new NotFoundError({ message: "Certificate Authority not found" });
+ }
+
+ const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
+
+ if (caType === CaType.INTERNAL) {
+ const certificateResult = await issueCertificateFromProfile({
+ profileId,
+ certificateRequest,
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId
+ });
+
+ const orderId = randomUUID();
+ const identifiers = certificateOrder.identifiers.map((id) => ({
+ type: id.type,
+ value: id.value,
+ status: "valid" as const
+ }));
+
+ const authorizations = certificateOrder.identifiers.map((id) => ({
+ identifier: {
+ type: id.type,
+ value: id.value
+ },
+ status: "valid" as const,
+ expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+ challenges: [
+ {
+ type: "internal-validation",
+ status: "valid" as const,
+ url: `/api/v3/certificates/orders/${orderId}/internal`,
+ token: "internal-ca-validation"
+ }
+ ]
+ }));
+
+ return {
+ orderId,
+ status: "valid",
+ identifiers,
+ authorizations,
+ finalize: `/api/v3/certificates/orders/${orderId}/finalize`,
+ certificate: certificateResult.certificate
+ };
+ }
+
+ if (caType === CaType.ACME) {
+ throw new BadRequestError({
+ message:
+ "ACME certificate ordering via profiles is not yet implemented. Use direct certificate issuance for ACME CAs."
+ });
+ }
+
+ throw new BadRequestError({
+ message: `Certificate ordering is not supported for CA type: ${caType}`
+ });
+ };
+
+ return {
+ issueCertificateFromProfile,
+ signCertificateFromProfile,
+ orderCertificateFromProfile
+ };
+};
diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts
new file mode 100644
index 000000000..b05cf2d28
--- /dev/null
+++ b/backend/src/services/certificate-v3/certificate-v3-types.ts
@@ -0,0 +1,104 @@
+import { TProjectPermission } from "@app/lib/types";
+
+import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types";
+
+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<{
+ type: "dns_name" | "ip_address" | "email" | "uri";
+ value: string;
+ }>;
+ validity: {
+ ttl: string;
+ };
+ notBefore?: Date;
+ notAfter?: Date;
+ signatureAlgorithm?: string;
+ keyAlgorithm?: string;
+ };
+} & Omit;
+
+export type TSignCertificateFromProfileDTO = {
+ profileId: string;
+ csr: string;
+ validity: {
+ ttl: string;
+ };
+ notBefore?: Date;
+ notAfter?: Date;
+} & Omit;
+
+export type TOrderCertificateFromProfileDTO = {
+ profileId: string;
+ certificateOrder: {
+ identifiers: Array<{
+ type: "dns" | "ip";
+ value: string;
+ }>;
+ validity: {
+ 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;
+ notAfter?: Date;
+ signatureAlgorithm?: string;
+ keyAlgorithm?: string;
+ };
+} & Omit;
+
+export type TCertificateFromProfileResponse = {
+ certificate: string;
+ issuingCaCertificate: string;
+ certificateChain: string;
+ privateKey?: string;
+ serialNumber: string;
+ certificateId: string;
+};
+
+export type TCertificateOrderResponse = {
+ orderId: string;
+ status: "pending" | "processing" | "valid" | "invalid";
+ identifiers: Array<{
+ type: "dns" | "ip";
+ value: string;
+ status: "pending" | "processing" | "valid" | "invalid";
+ }>;
+ authorizations: Array<{
+ identifier: {
+ type: "dns" | "ip";
+ value: string;
+ };
+ status: "pending" | "processing" | "valid" | "invalid";
+ expires?: string;
+ challenges: Array<{
+ type: string;
+ status: "pending" | "processing" | "valid" | "invalid";
+ url: string;
+ token: string;
+ }>;
+ }>;
+ finalize: string;
+ certificate?: string;
+};
diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts
new file mode 100644
index 000000000..558b5deda
--- /dev/null
+++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts
@@ -0,0 +1,103 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { DatabaseError } from "@app/lib/errors";
+import { ormify } from "@app/lib/knex";
+
+import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types";
+
+export type TApiEnrollmentConfigDALFactory = ReturnType;
+
+export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
+ const apiEnrollmentConfigOrm = ormify(db, TableName.ApiEnrollmentConfig);
+
+ const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => {
+ try {
+ const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).insert(data).returning("*");
+
+ return apiConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Create API enrollment config" });
+ }
+ };
+
+ const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => {
+ try {
+ const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).update(data).returning("*");
+
+ return apiConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Update API enrollment config" });
+ }
+ };
+
+ const deleteById = async (id: string, tx?: Knex) => {
+ try {
+ const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).del().returning("*");
+
+ return apiConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Delete API enrollment config" });
+ }
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const apiConfig = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).first();
+
+ return apiConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find API enrollment config by id" });
+ }
+ };
+
+ const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, tx?: Knex) => {
+ try {
+ const now = new Date();
+ const renewalDate = new Date();
+ renewalDate.setDate(now.getDate() + renewalThresholdDays);
+
+ const profiles = await (tx || db)(TableName.CertificateProfile)
+ .join(
+ TableName.ApiEnrollmentConfig,
+ `${TableName.CertificateProfile}.apiConfigId`,
+ `${TableName.ApiEnrollmentConfig}.id`
+ )
+ .where(`${TableName.ApiEnrollmentConfig}.autoRenew`, true)
+ .where((query) => {
+ void query
+ .whereNull(`${TableName.ApiEnrollmentConfig}.autoRenewDays`)
+ .orWhere(`${TableName.ApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
+ })
+ .select((tx || db).ref("id").withSchema(TableName.CertificateProfile))
+ .select((tx || db).ref("name").withSchema(TableName.CertificateProfile))
+ .select((tx || db).ref("projectId").withSchema(TableName.CertificateProfile))
+ .select((tx || db).ref("autoRenewDays").withSchema(TableName.CertificateProfile));
+
+ return profiles;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find profiles for auto renewal" });
+ }
+ };
+
+ const isConfigInUse = async (configId: string, tx?: Knex) => {
+ try {
+ const doc = await (tx || db)(TableName.CertificateProfile).where({ apiConfigId: configId }).count("*").first();
+
+ return parseInt(doc || "0", 10);
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Check if API enrollment config is in use" });
+ }
+ };
+
+ return {
+ ...apiEnrollmentConfigOrm,
+ create,
+ updateById,
+ deleteById,
+ findById,
+ findProfilesForAutoRenewal,
+ isConfigInUse
+ };
+};
diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts
new file mode 100644
index 000000000..1581f114c
--- /dev/null
+++ b/backend/src/services/enrollment-config/enrollment-config-types.ts
@@ -0,0 +1,29 @@
+import {
+ TApiEnrollmentConfigs,
+ TApiEnrollmentConfigsInsert,
+ TApiEnrollmentConfigsUpdate
+} from "@app/db/schemas/api-enrollment-configs";
+import {
+ TEstEnrollmentConfigs,
+ TEstEnrollmentConfigsInsert,
+ TEstEnrollmentConfigsUpdate
+} from "@app/db/schemas/est-enrollment-configs";
+
+export type TEstEnrollmentConfig = TEstEnrollmentConfigs;
+export type TEstEnrollmentConfigInsert = TEstEnrollmentConfigsInsert;
+export type TEstEnrollmentConfigUpdate = TEstEnrollmentConfigsUpdate;
+
+export type TApiEnrollmentConfig = TApiEnrollmentConfigs;
+export type TApiEnrollmentConfigInsert = TApiEnrollmentConfigsInsert;
+export type TApiEnrollmentConfigUpdate = TApiEnrollmentConfigsUpdate;
+
+export interface TEstConfigData {
+ disableBootstrapCaValidation: boolean;
+ passphrase: string;
+ encryptedCaChain: string;
+}
+
+export interface TApiConfigData {
+ autoRenew: boolean;
+ autoRenewDays?: number;
+}
diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts
new file mode 100644
index 000000000..0da507225
--- /dev/null
+++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts
@@ -0,0 +1,76 @@
+import { Knex } from "knex";
+
+import { TDbClient } from "@app/db";
+import { TableName } from "@app/db/schemas";
+import { DatabaseError } from "@app/lib/errors";
+import { ormify } from "@app/lib/knex";
+
+import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types";
+
+export type TEstEnrollmentConfigDALFactory = ReturnType;
+
+export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
+ const estEnrollmentConfigOrm = ormify(db, TableName.EstEnrollmentConfig);
+
+ const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => {
+ try {
+ const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).insert(data).returning("*");
+
+ return estConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Create EST enrollment config" });
+ }
+ };
+
+ const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => {
+ try {
+ const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).update(data).returning("*");
+
+ return estConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Update EST enrollment config" });
+ }
+ };
+
+ const deleteById = async (id: string, tx?: Knex) => {
+ try {
+ const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).del().returning("*");
+
+ return estConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Delete EST enrollment config" });
+ }
+ };
+
+ const findById = async (id: string, tx?: Knex) => {
+ try {
+ const estConfig = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).first();
+
+ return estConfig;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Find EST enrollment config by id" });
+ }
+ };
+
+ const isConfigInUse = async (configId: string, tx?: Knex) => {
+ try {
+ const profileCount = await (tx || db)(TableName.CertificateProfile)
+ .where({ estConfigId: configId })
+ .count("* as count")
+ .first();
+
+ return parseInt(profileCount || "0", 10) > 0;
+ } catch (error) {
+ throw new DatabaseError({ error, name: "Check if EST enrollment config is in use" });
+ }
+ };
+
+ return {
+ ...estEnrollmentConfigOrm,
+ create,
+ updateById,
+ deleteById,
+ findById,
+ isConfigInUse
+ };
+};
From 4ddce0688781ec09cdf8476f05c062a3e575fd75 Mon Sep 17 00:00:00 2001
From: Carlos Monastyrski
Date: Thu, 9 Oct 2025 00:49:05 -0300
Subject: [PATCH 002/148] Removed test specs used for development
---
.../routes/est/certificate-est.spec.ts | 428 ---------
.../routes/v1/certificate-profiles.spec.ts | 391 --------
.../v2/certificate-templates-v2.spec.ts | 292 ------
.../e2e-test/routes/v3/certificates.spec.ts | 855 ------------------
4 files changed, 1966 deletions(-)
delete mode 100644 backend/e2e-test/routes/est/certificate-est.spec.ts
delete mode 100644 backend/e2e-test/routes/v1/certificate-profiles.spec.ts
delete mode 100644 backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
delete mode 100644 backend/e2e-test/routes/v3/certificates.spec.ts
diff --git a/backend/e2e-test/routes/est/certificate-est.spec.ts b/backend/e2e-test/routes/est/certificate-est.spec.ts
deleted file mode 100644
index 58e7a1151..000000000
--- a/backend/e2e-test/routes/est/certificate-est.spec.ts
+++ /dev/null
@@ -1,428 +0,0 @@
-import { seedData1 } from "@app/db/seed-data";
-
-describe("Certificate EST Router", () => {
- let projectId: string;
- let certificateAuthorityId: string;
- let templateId: string;
- let profileId: string;
- let estPassphrase: string;
-
- beforeAll(async () => {
- projectId = seedData1.project.id;
- estPassphrase = "test-est-passphrase";
-
- // Create a test certificate authority first
- const caRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/pki/ca",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectSlug: seedData1.project.slug,
- type: "root",
- friendlyName: "Test Root CA for EST",
- organization: "Test Org",
- ou: "Test OU",
- country: "US",
- province: "CA",
- locality: "San Francisco",
- commonName: "Test Root CA for EST",
- ttl: "8760h"
- }
- });
- expect(caRes.statusCode).toBe(200);
- const caPayload = JSON.parse(caRes.payload);
- certificateAuthorityId = caPayload.certificateAuthority.id;
-
- // Create a test certificate template v2
- const templateRes = await testServer.inject({
- method: "POST",
- url: "/api/v2/certificate-templates",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- name: "Test Template for EST",
- policy: {
- allowedDomains: ["est.example.com", "*.est.example.com"],
- allowWildcards: true,
- maxTtl: "8760h"
- }
- }
- });
- expect(templateRes.statusCode).toBe(200);
- const templatePayload = JSON.parse(templateRes.payload);
- templateId = templatePayload.certificateTemplate.id;
-
- // Create a test certificate profile with EST enrollment
- const profileRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Test EST Profile",
- slug: "test-est-profile",
- enrollmentMethod: "est",
- enrollmentConfig: {
- estConfig: {
- passphrase: estPassphrase,
- disableBootstrapCaValidation: false,
- encryptedCaChain: "test-encrypted-ca-chain"
- }
- }
- }
- });
- expect(profileRes.statusCode).toBe(200);
- const profilePayload = JSON.parse(profileRes.payload);
- profileId = profilePayload.certificateProfile.id;
- });
-
- describe("GET /:identifier/cacerts", () => {
- test("Should get CA certificates using profileId (no authentication required)", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/est/${profileId}/cacerts`
- });
-
- expect(res.statusCode).toBe(200);
- expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
- expect(res.headers["content-transfer-encoding"]).toBe("base64");
- expect(typeof res.payload).toBe("string");
- });
-
- test("Should get CA certificates using legacy templateId (backward compatibility)", async () => {
- // First enable EST on the legacy template
- const enableEstRes = await testServer.inject({
- method: "PATCH",
- url: `/api/v1/pki/templates/${templateId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- estConfig: {
- isEnabled: true,
- passphrase: estPassphrase
- }
- }
- });
- expect(enableEstRes.statusCode).toBe(200);
-
- const res = await testServer.inject({
- method: "GET",
- url: `/api/est/${templateId}/cacerts`
- });
-
- expect(res.statusCode).toBe(200);
- expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
- });
-
- test("Should return 404 for non-existent identifier", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: "/api/est/non-existent-id/cacerts"
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("Authentication for EST endpoints", () => {
- test("Should require authentication for simpleenroll", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- body: "test-csr"
- });
-
- expect(res.statusCode).toBe(401);
- expect(res.headers["www-authenticate"]).toBe('Basic realm="infisical"');
- });
-
- test("Should require authentication for simplereenroll", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simplereenroll`,
- body: "test-csr"
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should reject invalid credentials", async () => {
- const invalidAuth = Buffer.from("user:wrongpassword").toString("base64");
-
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${invalidAuth}`,
- "content-type": "application/pkcs10"
- },
- body: "test-csr"
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should accept valid credentials", async () => {
- const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
-
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: "test-csr"
- });
-
- expect(res.statusCode).not.toBe(401);
- });
- });
-
- describe("POST /:identifier/simpleenroll", () => {
- let validAuth: string;
- let testCSR: string;
-
- beforeAll(() => {
- validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
- // Mock CSR for testing
- testCSR = `-----BEGIN CERTIFICATE REQUEST-----
-MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
-AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
-...mock CSR content...
-yK5ZqN8U3QR7yB+X9vG1eI+dA==
------END CERTIFICATE REQUEST-----`;
- });
-
- test("Should process enrollment request with profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: testCSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
-
- if (res.statusCode === 200) {
- expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
- expect(res.headers["content-transfer-encoding"]).toBe("base64");
- }
- });
-
- test("Should process enrollment request with legacy templateId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${templateId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: testCSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
- });
-
- test("Should handle PEM format CSR", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: testCSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
- });
-
- test("Should handle base64 format CSR", async () => {
- const base64CSR = testCSR
- .replace(/-----BEGIN CERTIFICATE REQUEST-----/, "")
- .replace(/-----END CERTIFICATE REQUEST-----/, "")
- .replace(/\n/g, "");
-
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: base64CSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
- });
-
- test("Should fail with empty CSR", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: ""
- });
-
- expect(res.statusCode).toBe(400);
- });
- });
-
- describe("POST /:identifier/simplereenroll", () => {
- let validAuth: string;
- let testCSR: string;
-
- beforeAll(() => {
- validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
- testCSR = `-----BEGIN CERTIFICATE REQUEST-----
-MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
-AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
-...mock CSR content...
-yK5ZqN8U3QR7yB+X9vG1eI+dA==
------END CERTIFICATE REQUEST-----`;
- });
-
- test("Should process re-enrollment request with profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simplereenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: testCSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
-
- if (res.statusCode === 200) {
- expect(res.headers["content-type"]).toContain("application/pkcs7-mime");
- expect(res.headers["content-transfer-encoding"]).toBe("base64");
- }
- });
-
- test("Should process re-enrollment request with legacy templateId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${templateId}/simplereenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: testCSR
- });
-
- expect([200, 400]).toContain(res.statusCode);
- });
- });
-
- describe("EST Configuration Validation", () => {
- test("Should fail when EST is disabled on profile", async () => {
- // Create a profile with EST disabled
- const disabledProfileRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Disabled EST Profile",
- slug: "disabled-est-profile",
- enrollmentMethod: "api" // Not EST
- }
- });
- expect(disabledProfileRes.statusCode).toBe(200);
- const disabledProfile = JSON.parse(disabledProfileRes.payload);
-
- const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
-
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${disabledProfile.certificateProfile.id}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: "test-csr"
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should handle missing authorization header gracefully", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- body: "test-csr"
- });
-
- expect(res.statusCode).toBe(401);
- expect(res.headers["www-authenticate"]).toBeDefined();
- });
- });
-
- describe("Content Type Handling", () => {
- test("Should handle different content types for CSR", async () => {
- const validAuth = Buffer.from(`user:${estPassphrase}`).toString("base64");
-
- const res = await testServer.inject({
- method: "POST",
- url: `/api/est/${profileId}/simpleenroll`,
- headers: {
- authorization: `Basic ${validAuth}`,
- "content-type": "application/pkcs10"
- },
- body: "test-csr-content"
- });
-
- // Should not fail due to content type
- expect(res.statusCode).not.toBe(415);
- });
- });
-
- describe("Profile vs Template Identifier Detection", () => {
- test("Should correctly identify UUID as profileId", async () => {
- const isUUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- expect(isUUIDPattern.test(profileId)).toBe(true);
-
- const res = await testServer.inject({
- method: "GET",
- url: `/api/est/${profileId}/cacerts`
- });
-
- expect(res.statusCode).toBe(200);
- });
-
- test("Should correctly identify non-UUID as legacy templateId", async () => {
- const isUUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- expect(isUUIDPattern.test(templateId)).toBe(false);
-
- const res = await testServer.inject({
- method: "GET",
- url: `/api/est/${templateId}/cacerts`
- });
-
- expect(res.statusCode).toBe(200);
- });
- });
-});
diff --git a/backend/e2e-test/routes/v1/certificate-profiles.spec.ts b/backend/e2e-test/routes/v1/certificate-profiles.spec.ts
deleted file mode 100644
index 23d760eb5..000000000
--- a/backend/e2e-test/routes/v1/certificate-profiles.spec.ts
+++ /dev/null
@@ -1,391 +0,0 @@
-import { seedData1 } from "@app/db/seed-data";
-
-describe("Certificate Profiles Router", () => {
- let projectId: string;
- let certificateAuthorityId: string;
- let templateId: string;
- let profileId: string;
-
- beforeAll(async () => {
- projectId = seedData1.project.id;
-
- // Create a test certificate authority first
- const caRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/pki/ca",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectSlug: seedData1.project.slug,
- type: "root",
- friendlyName: "Test Root CA",
- organization: "Test Org",
- ou: "Test OU",
- country: "US",
- province: "CA",
- locality: "San Francisco",
- commonName: "Test Root CA",
- ttl: "8760h"
- }
- });
- expect(caRes.statusCode).toBe(200);
- const caPayload = JSON.parse(caRes.payload);
- certificateAuthorityId = caPayload.certificateAuthority.id;
-
- // Create a test certificate template v2
- const templateRes = await testServer.inject({
- method: "POST",
- url: "/api/v2/certificate-templates",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- name: "Test Template for Profile",
- policy: {
- allowedDomains: ["example.com"],
- maxTtl: "8760h"
- }
- }
- });
- expect(templateRes.statusCode).toBe(200);
- const templatePayload = JSON.parse(templateRes.payload);
- templateId = templatePayload.certificateTemplate.id;
- });
-
- describe("POST /v1/certificate-profiles", () => {
- test("Should create a certificate profile", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Test Profile",
- slug: "test-profile",
- description: "A test certificate profile",
- enrollmentMethod: "api",
- enrollmentConfig: {
- apiConfig: {
- autoRenew: true,
- autoRenewDays: 30
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateProfile");
- expect(payload.certificateProfile).toHaveProperty("id");
- expect(payload.certificateProfile.name).toBe("Test Profile");
- expect(payload.certificateProfile.slug).toBe("test-profile");
- expect(payload.certificateProfile.projectId).toBe(projectId);
- expect(payload.certificateProfile.certificateAuthorityId).toBe(certificateAuthorityId);
- expect(payload.certificateProfile.certificateTemplateId).toBe(templateId);
-
- profileId = payload.certificateProfile.id;
- });
-
- test("Should create profile with EST enrollment", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "EST Profile",
- slug: "est-profile",
- enrollmentMethod: "est",
- enrollmentConfig: {
- estConfig: {
- passphrase: "test-passphrase",
- disableBootstrapCaValidation: false,
- encryptedCaChain: "encrypted-ca-chain-data"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificateProfile.enrollmentMethod).toBe("est");
- });
-
- test("Should fail to create profile with invalid project", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId: "invalid-project-id",
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Invalid Profile",
- slug: "invalid-profile"
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should fail to create profile with duplicate slug", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Duplicate Profile",
- slug: "test-profile" // Same slug as first profile
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
- });
-
- describe("GET /v1/certificate-profiles", () => {
- test("Should list certificate profiles", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles?projectId=${projectId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateProfiles");
- expect(payload).toHaveProperty("totalCount");
- expect(Array.isArray(payload.certificateProfiles)).toBe(true);
- expect(payload.totalCount).toBeGreaterThan(0);
- });
-
- test("Should support pagination and search", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles?projectId=${projectId}&offset=0&limit=1&search=Test`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificateProfiles.length).toBeLessThanOrEqual(1);
- });
- });
-
- describe("GET /v1/certificate-profiles/:id", () => {
- test("Should get certificate profile by ID with configs", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/${profileId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateProfile");
- expect(payload.certificateProfile.id).toBe(profileId);
- expect(payload.certificateProfile.name).toBe("Test Profile");
- expect(payload.certificateProfile).toHaveProperty("certificateAuthority");
- expect(payload.certificateProfile).toHaveProperty("certificateTemplate");
- expect(payload.certificateProfile).toHaveProperty("apiConfig");
- });
-
- test("Should return 404 for non-existent profile", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: "/api/v1/certificate-profiles/non-existent-id",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("GET /v1/certificate-profiles/slug/:slug", () => {
- test("Should get certificate profile by slug", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/slug/test-profile?projectId=${projectId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateProfile");
- expect(payload.certificateProfile.slug).toBe("test-profile");
- });
-
- test("Should return 404 for non-existent slug", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/slug/non-existent?projectId=${projectId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("PATCH /v1/certificate-profiles/:id", () => {
- test("Should update certificate profile", async () => {
- const res = await testServer.inject({
- method: "PATCH",
- url: `/api/v1/certificate-profiles/${profileId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- name: "Updated Test Profile",
- description: "Updated description",
- enrollmentConfig: {
- apiConfig: {
- autoRenew: false,
- autoRenewDays: 60
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificateProfile.name).toBe("Updated Test Profile");
- expect(payload.certificateProfile.description).toBe("Updated description");
- });
- });
-
- describe("GET /v1/certificate-profiles/:id/certificates", () => {
- test("Should list certificates for profile", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/${profileId}/certificates`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificates");
- expect(Array.isArray(payload.certificates)).toBe(true);
- });
-
- test("Should support filtering and pagination", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/${profileId}/certificates?status=active&offset=0&limit=10`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificates.length).toBeLessThanOrEqual(10);
- });
- });
-
- describe("GET /v1/certificate-profiles/:id/metrics", () => {
- test("Should get profile metrics", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles/${profileId}/metrics?expiringDays=30`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("metrics");
- expect(payload.metrics).toHaveProperty("profileId");
- expect(payload.metrics).toHaveProperty("totalCertificates");
- expect(payload.metrics).toHaveProperty("activeCertificates");
- expect(payload.metrics).toHaveProperty("expiredCertificates");
- expect(payload.metrics).toHaveProperty("expiringCertificates");
- expect(payload.metrics).toHaveProperty("revokedCertificates");
- expect(payload.metrics.profileId).toBe(profileId);
- });
- });
-
- describe("DELETE /v1/certificate-profiles/:id", () => {
- test("Should delete certificate profile", async () => {
- const res = await testServer.inject({
- method: "DELETE",
- url: `/api/v1/certificate-profiles/${profileId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateProfile");
- expect(payload.certificateProfile.id).toBe(profileId);
- });
-
- test("Should return 404 when deleting non-existent profile", async () => {
- const res = await testServer.inject({
- method: "DELETE",
- url: `/api/v1/certificate-profiles/${profileId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("Authentication", () => {
- test("Should require authentication", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles?projectId=${projectId}`
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should reject invalid token", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v1/certificate-profiles?projectId=${projectId}`,
- headers: {
- authorization: "Bearer invalid-token"
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
- });
-});
diff --git a/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts b/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
deleted file mode 100644
index 94b583829..000000000
--- a/backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
+++ /dev/null
@@ -1,292 +0,0 @@
-import { seedData1 } from "@app/db/seed-data";
-
-describe("Certificate Templates V2 Router", () => {
- let projectId: string;
- let certificateAuthorityId: string;
- let templateId: string;
-
- beforeAll(async () => {
- projectId = seedData1.project.id;
-
- // Create a test certificate authority first
- const caRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/pki/ca",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectSlug: seedData1.project.slug,
- type: "root",
- friendlyName: "Test Root CA",
- organization: "Test Org",
- ou: "Test OU",
- country: "US",
- province: "CA",
- locality: "San Francisco",
- commonName: "Test Root CA",
- ttl: "8760h"
- }
- });
- expect(caRes.statusCode).toBe(200);
- const caPayload = JSON.parse(caRes.payload);
- certificateAuthorityId = caPayload.certificateAuthority.id;
- });
-
- describe("POST /v2/certificate-templates", () => {
- test("Should create a certificate template v2", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v2/certificate-templates",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- name: "Test Template V2",
- description: "A test certificate template v2",
- policy: {
- allowedDomains: ["example.com", "*.example.com"],
- allowWildcards: true,
- allowAnyName: false,
- allowIpSans: false,
- allowSubdomains: true,
- maxTtl: "8760h",
- keyUsages: ["digital_signature", "key_agreement"],
- extendedKeyUsages: ["server_auth", "client_auth"],
- organizationPolicy: {
- allowedOrganizations: ["Test Org"],
- enforceOrganization: true
- },
- subjectPolicy: {
- allowedCountries: ["US", "CA"],
- allowedProvinces: ["CA", "NY"],
- allowedLocalities: ["San Francisco", "New York"],
- enforceSubject: false
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateTemplate");
- expect(payload.certificateTemplate).toHaveProperty("id");
- expect(payload.certificateTemplate.name).toBe("Test Template V2");
- expect(payload.certificateTemplate.projectId).toBe(projectId);
- expect(payload.certificateTemplate.certificateAuthorityId).toBe(certificateAuthorityId);
-
- templateId = payload.certificateTemplate.id;
- });
-
- test("Should fail to create template with invalid project", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v2/certificate-templates",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId: "invalid-project-id",
- certificateAuthorityId,
- name: "Invalid Template",
- policy: {}
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
- });
-
- describe("GET /v2/certificate-templates", () => {
- test("Should list certificate templates v2", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v2/certificate-templates?projectId=${projectId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateTemplates");
- expect(payload).toHaveProperty("totalCount");
- expect(Array.isArray(payload.certificateTemplates)).toBe(true);
- expect(payload.totalCount).toBeGreaterThan(0);
- });
-
- test("Should support pagination", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v2/certificate-templates?projectId=${projectId}&offset=0&limit=1`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificateTemplates.length).toBeLessThanOrEqual(1);
- });
- });
-
- describe("GET /v2/certificate-templates/:id", () => {
- test("Should get certificate template v2 by ID", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v2/certificate-templates/${templateId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateTemplate");
- expect(payload.certificateTemplate.id).toBe(templateId);
- expect(payload.certificateTemplate.name).toBe("Test Template V2");
- });
-
- test("Should return 404 for non-existent template", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: "/api/v2/certificate-templates/non-existent-id",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("PATCH /v2/certificate-templates/:id", () => {
- test("Should update certificate template v2", async () => {
- const res = await testServer.inject({
- method: "PATCH",
- url: `/api/v2/certificate-templates/${templateId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- name: "Updated Test Template V2",
- description: "Updated description",
- policy: {
- allowedDomains: ["updated.com"],
- allowWildcards: false,
- maxTtl: "4380h"
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.certificateTemplate.name).toBe("Updated Test Template V2");
- expect(payload.certificateTemplate.description).toBe("Updated description");
- });
- });
-
- describe("POST /v2/certificate-templates/:id/validate", () => {
- test("Should validate certificate request against template policy", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/v2/certificate-templates/${templateId}/validate`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- request: {
- commonName: "test.updated.com",
- ttl: "24h",
- keyUsages: ["digital_signature"],
- extendedKeyUsages: ["server_auth"]
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("valid");
- expect(typeof payload.valid).toBe("boolean");
- if (!payload.valid) {
- expect(payload).toHaveProperty("errors");
- expect(Array.isArray(payload.errors)).toBe(true);
- }
- });
-
- test("Should reject invalid certificate request", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: `/api/v2/certificate-templates/${templateId}/validate`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- request: {
- commonName: "invalid.domain.com", // Not in allowed domains
- ttl: "24h"
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload.valid).toBe(false);
- expect(payload.errors).toBeDefined();
- });
- });
-
- describe("DELETE /v2/certificate-templates/:id", () => {
- test("Should delete certificate template v2", async () => {
- const res = await testServer.inject({
- method: "DELETE",
- url: `/api/v2/certificate-templates/${templateId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateTemplate");
- expect(payload.certificateTemplate.id).toBe(templateId);
- });
-
- test("Should return 404 when deleting non-existent template", async () => {
- const res = await testServer.inject({
- method: "DELETE",
- url: `/api/v2/certificate-templates/${templateId}`,
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("Authentication", () => {
- test("Should require authentication", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v2/certificate-templates?projectId=${projectId}`
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should reject invalid token", async () => {
- const res = await testServer.inject({
- method: "GET",
- url: `/api/v2/certificate-templates?projectId=${projectId}`,
- headers: {
- authorization: "Bearer invalid-token"
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
- });
-});
diff --git a/backend/e2e-test/routes/v3/certificates.spec.ts b/backend/e2e-test/routes/v3/certificates.spec.ts
deleted file mode 100644
index b75e7c935..000000000
--- a/backend/e2e-test/routes/v3/certificates.spec.ts
+++ /dev/null
@@ -1,855 +0,0 @@
-import { seedData1 } from "@app/db/seed-data";
-
-describe("Certificates V3 Router", () => {
- let projectId: string;
- let certificateAuthorityId: string;
- let templateId: string;
- let profileId: string;
-
- beforeAll(async () => {
- projectId = seedData1.project.id;
-
- // Create a test certificate authority first
- const caRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/pki/ca",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectSlug: seedData1.project.slug,
- type: "root",
- friendlyName: "Test Root CA",
- organization: "Test Org",
- ou: "Test OU",
- country: "US",
- province: "CA",
- locality: "San Francisco",
- commonName: "Test Root CA",
- ttl: "8760h"
- }
- });
- expect(caRes.statusCode).toBe(200);
- const caPayload = JSON.parse(caRes.payload);
- certificateAuthorityId = caPayload.certificateAuthority.id;
-
- // Create a test certificate template v2 with proper V2 structure
- const templateRes = await testServer.inject({
- method: "POST",
- url: "/api/v2/certificate-templates",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- name: "Test Template for V3",
- description: "Template for testing V3 certificate endpoints",
- attributes: [
- {
- type: "common_name",
- include: "optional",
- value: ["*.example.com", "example.com"]
- },
- {
- type: "organization_name",
- include: "optional"
- },
- {
- type: "country",
- include: "optional",
- value: ["US", "CA"]
- }
- ],
- keyUsages: {
- requiredUsages: {
- all: ["digital_signature"]
- },
- optionalUsages: {
- all: ["key_encipherment", "key_agreement"]
- }
- },
- extendedKeyUsages: {
- requiredUsages: {
- all: ["server_auth"]
- },
- optionalUsages: {
- all: ["client_auth"]
- }
- },
- subjectAlternativeNames: [
- {
- type: "dns_name",
- include: "optional",
- value: ["*.example.com", "example.com"]
- }
- ],
- validity: {
- maxDuration: {
- value: 365,
- unit: "days"
- }
- }
- }
- });
- expect(templateRes.statusCode).toBe(200);
- const templatePayload = JSON.parse(templateRes.payload);
- templateId = templatePayload.certificateTemplate.id;
-
- // Create a test certificate profile
- const profileRes = await testServer.inject({
- method: "POST",
- url: "/api/v1/certificate-profiles",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- projectId,
- certificateAuthorityId,
- certificateTemplateId: templateId,
- name: "Test Profile for V3",
- slug: "test-profile-v3",
- enrollmentMethod: "api",
- enrollmentConfig: {
- apiConfig: {
- autoRenew: false
- }
- }
- }
- });
- expect(profileRes.statusCode).toBe(200);
- const profilePayload = JSON.parse(profileRes.payload);
- profileId = profilePayload.certificateProfile.id;
- });
-
- describe("POST /v3/certificates/issue-certificate", () => {
- test("Should issue a certificate using profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- organization: "Test Org",
- organizationUnit: "Test OU",
- locality: "San Francisco",
- state: "CA",
- country: "US",
- email: "test@example.com",
- keyUsages: ["digitalSignature"],
- extendedKeyUsages: ["serverAuth"],
- subjectAlternativeNames: [
- {
- type: "dns_name",
- value: "www.example.com"
- },
- {
- type: "dns_name",
- value: "api.example.com"
- }
- ],
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificate");
- expect(payload).toHaveProperty("issuingCaCertificate");
- expect(payload).toHaveProperty("certificateChain");
- expect(payload).toHaveProperty("privateKey");
- expect(payload).toHaveProperty("serialNumber");
- expect(payload).toHaveProperty("certificateId");
-
- // Verify certificate fields
- expect(typeof payload.certificate).toBe("string");
- expect(payload.certificate.includes("BEGIN CERTIFICATE")).toBe(true);
- expect(typeof payload.privateKey).toBe("string");
- expect(payload.privateKey.includes("BEGIN PRIVATE KEY")).toBe(true);
- });
-
- test("Should fail with invalid profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId: "invalid-profile-id",
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
-
- test("Should fail with domain not allowed by policy", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.notallowed.com",
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should fail with invalid TTL", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "invalid-ttl"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should handle wildcard certificates", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "*.example.com",
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificate");
- });
-
- test("Should validate certificate with profile policy", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "app.example.com",
- keyUsages: ["digitalSignature"], // Required by template
- extendedKeyUsages: ["serverAuth"], // Required by template
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificate");
- expect(payload).toHaveProperty("certificateId");
-
- // Verify the certificate was created with profile reference
- expect(typeof payload.certificateId).toBe("string");
- expect(payload.certificateId.length).toBeGreaterThan(0);
- });
- });
-
- describe("POST /v3/certificates/sign-certificate", () => {
- let testCSR: string;
-
- beforeAll(() => {
- // Mock CSR for testing - in real tests you'd generate a proper CSR
- testCSR = `-----BEGIN CERTIFICATE REQUEST-----
-MIICWjCCAUICAQAwFTETMBEGA1UEAwwKdGVzdC5jb20uY2ExXDANBgkqhkiG9w0B
-AQEFAAOBiQAwgYUCgYEAyKdVQNK5Wf7V8qU2tU3hV7g4+OJ+Xz8TzL1Q2u8cQ9v
-...mock CSR content...
-yK5ZqN8U3QR7yB+X9vG1eI+dA==
------END CERTIFICATE REQUEST-----`;
- });
-
- test("Should sign a CSR using profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/sign-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- csr: testCSR,
- validity: {
- ttl: "24h"
- }
- }
- });
-
- expect([200, 400]).toContain(res.statusCode);
-
- if (res.statusCode === 200) {
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificate");
- expect(payload).toHaveProperty("issuingCaCertificate");
- expect(payload).toHaveProperty("certificateChain");
- expect(payload).toHaveProperty("serialNumber");
- expect(payload).toHaveProperty("certificateId");
-
- // Verify the certificate was created with profile reference
- expect(typeof payload.certificateId).toBe("string");
- expect(payload.certificateId.length).toBeGreaterThan(0);
- }
- });
-
- test("Should fail with invalid CSR", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/sign-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- csr: "invalid-csr",
- validity: {
- ttl: "24h"
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should fail with empty CSR", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/sign-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- csr: "",
- validity: {
- ttl: "24h"
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should fail with invalid profileId for CSR signing", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/sign-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId: "invalid-profile-id",
- csr: testCSR,
- validity: {
- ttl: "24h"
- }
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("POST /v3/certificates/order-certificate", () => {
- test("Should create a certificate order using profileId", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateOrder: {
- identifiers: [
- {
- type: "dns",
- value: "test.example.com"
- },
- {
- type: "dns",
- value: "www.example.com"
- }
- ],
- validity: {
- ttl: "24h"
- },
- commonName: "test.example.com",
- keyUsages: ["digitalSignature"],
- extendedKeyUsages: ["serverAuth"],
- organization: "Test Org"
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("orderId");
- expect(payload).toHaveProperty("status");
- expect(payload).toHaveProperty("identifiers");
- expect(payload).toHaveProperty("authorizations");
- expect(payload).toHaveProperty("finalize");
-
- // Verify order structure
- expect(Array.isArray(payload.identifiers)).toBe(true);
- expect(payload.identifiers.length).toBe(2);
- expect(Array.isArray(payload.authorizations)).toBe(true);
- expect(["pending", "processing", "valid", "invalid"]).toContain(payload.status);
- });
-
- test("Should fail with invalid identifiers", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateOrder: {
- identifiers: [], // Empty identifiers
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should fail with disallowed domains", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateOrder: {
- identifiers: [
- {
- type: "dns",
- value: "test.notallowed.com"
- }
- ],
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should handle IP identifiers", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateOrder: {
- identifiers: [
- {
- type: "ip",
- value: "192.168.1.1"
- }
- ],
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect([200, 400]).toContain(res.statusCode);
- });
-
- test("Should validate order against template policy", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateOrder: {
- identifiers: [
- {
- type: "dns",
- value: "api.example.com"
- }
- ],
- validity: {
- ttl: "24h"
- },
- commonName: "api.example.com",
- keyUsages: ["digitalSignature"], // Required by template
- extendedKeyUsages: ["serverAuth"], // Required by template
- organization: "Test Org"
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("orderId");
- expect(payload.status).toBe("valid");
- });
-
- test("Should fail with invalid profileId for ordering", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId: "invalid-profile-id",
- certificateOrder: {
- identifiers: [
- {
- type: "dns",
- value: "test.example.com"
- }
- ],
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(404);
- });
- });
-
- describe("Validation and Error Handling", () => {
- test("Should validate TTL format", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "0h" // Invalid TTL
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should validate country code format", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- },
- country: "USA" // Invalid - should be 2 characters
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should validate email format", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- },
- email: "invalid-email" // Invalid email format
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should validate key usage requirements", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- },
- keyUsages: [], // Missing required key usages
- extendedKeyUsages: [] // Missing required extended key usages
- }
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
-
- test("Should handle empty certificate request", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {} // Empty certificate request
- }
- });
-
- expect(res.statusCode).toBe(400);
- });
- });
-
- describe("Authentication", () => {
- test("Should require authentication", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should reject invalid token", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: "Bearer invalid-token"
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "test.example.com",
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should require authentication for CSR signing", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/sign-certificate",
- body: {
- profileId,
- csr: "mock-csr",
- validity: {
- ttl: "24h"
- }
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
-
- test("Should require authentication for certificate ordering", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/order-certificate",
- body: {
- profileId,
- certificateOrder: {
- identifiers: [
- {
- type: "dns",
- value: "test.example.com"
- }
- ],
- validity: {
- ttl: "24h"
- }
- }
- }
- });
-
- expect(res.statusCode).toBe(401);
- });
- });
-
- describe("Profile-based Certificate Management", () => {
- test("Should create certificates with profile reference", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "profile-test.example.com",
- validity: {
- ttl: "24h"
- },
- keyUsages: ["digitalSignature"],
- extendedKeyUsages: ["serverAuth"]
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
-
- // Verify that the certificate was created successfully
- expect(payload).toHaveProperty("certificateId");
- expect(typeof payload.certificateId).toBe("string");
- expect(payload.certificateId.length).toBeGreaterThan(0);
-
- // Verify all required fields are present
- expect(payload).toHaveProperty("certificate");
- expect(payload).toHaveProperty("certificateChain");
- expect(payload).toHaveProperty("privateKey");
- expect(payload).toHaveProperty("serialNumber");
- expect(payload).toHaveProperty("issuingCaCertificate");
- });
-
- test("Should validate using template policy without template dependency", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "policy-test.example.com",
- validity: {
- ttl: "24h"
- },
- // Include required key usages as defined in template
- keyUsages: ["digitalSignature"],
- extendedKeyUsages: ["serverAuth"],
- // Test optional attributes
- organization: "Policy Test Org",
- country: "US"
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificate");
- expect(payload).toHaveProperty("certificateId");
- });
-
- test("Should handle profile permissions correctly", async () => {
- const res = await testServer.inject({
- method: "POST",
- url: "/api/v3/certificates/issue-certificate",
- headers: {
- authorization: `Bearer ${jwtAuthToken}`
- },
- body: {
- profileId,
- certificateRequest: {
- commonName: "permission-test.example.com",
- validity: {
- ttl: "24h"
- },
- keyUsages: ["digitalSignature"],
- extendedKeyUsages: ["serverAuth"]
- }
- }
- });
-
- expect(res.statusCode).toBe(200);
- const payload = JSON.parse(res.payload);
- expect(payload).toHaveProperty("certificateId");
- });
- });
-});
From 15a6b8da0423c9834992325c55980a2ace9b198f Mon Sep 17 00:00:00 2001
From: Carlos Monastyrski
Date: Fri, 10 Oct 2025 01:50:10 -0300
Subject: [PATCH 003/148] PKI revamp: frontend implementation
---
.../services/license/__mocks__/license-fns.ts | 3 +-
.../src/ee/services/license/license-fns.ts | 1 +
.../src/ee/services/license/license-types.ts | 1 +
.../routes/v1/certificate-profiles-router.ts | 91 +-
.../server/routes/v3/certificates-router.ts | 32 -
.../certificate-profile-dal.ts | 64 +-
.../certificate-profile-service.test.ts | 50 +-
.../certificate-profile-service.ts | 60 +-
.../certificate-profile-types.ts | 11 +-
.../certificate-template-v2-schemas.ts | 20 +-
.../certificate-template-v2-service.test.ts | 65 +-
.../certificate-template-v2-service.ts | 85 +-
.../certificate-template-v2-types.ts | 19 +-
.../certificate-v3-service.test.ts | 1 -
.../certificate-v3/certificate-v3-service.ts | 8 -
.../certificate-v3/certificate-v3-types.ts | 16 -
.../SecretSyncReviewFields.tsx | 5 +-
frontend/src/hooks/api/ca/index.tsx | 1 +
frontend/src/hooks/api/ca/mutations.tsx | 20 +
frontend/src/hooks/api/ca/types.ts | 26 +
.../hooks/api/certificateProfiles/index.ts | 14 +
.../api/certificateProfiles/mutations.tsx | 71 ++
.../hooks/api/certificateProfiles/queries.tsx | 154 +++
.../hooks/api/certificateProfiles/types.ts | 130 +++
.../api/certificateTemplates/mutations.tsx | 61 +
.../api/certificateTemplates/queries.tsx | 55 +-
.../hooks/api/certificateTemplates/types.ts | 98 ++
.../src/hooks/api/certificates/constants.tsx | 11 +
frontend/src/hooks/api/certificates/enums.tsx | 1 +
frontend/src/hooks/api/subscriptions/types.ts | 1 +
.../PkiManagerLayout/PkiManagerLayout.tsx | 79 +-
.../components/CaModal.tsx | 8 +-
.../components/CertificateIssuanceModal.tsx | 1018 +++++++++++++++++
.../components/CertificatesSection.tsx | 18 +-
.../components/PkiSubscriberSection.tsx | 38 +-
.../PkiTemplateListPage.tsx | 1 +
.../PoliciesPage/PoliciesPage.tsx | 55 +
.../CertificateProfilesTab.tsx | 115 ++
.../CreateProfileModal.tsx | 438 +++++++
.../EditProfileModal.tsx | 303 +++++
.../CertificateProfilesTab/ProfileList.tsx | 71 ++
.../CertificateProfilesTab/ProfileRow.tsx | 182 +++
.../CertificateProfilesTab/index.ts | 4 +
.../CertificateTemplatesV2Tab.tsx | 113 ++
.../CreateTemplateModal.tsx | 699 +++++++++++
.../EditTemplateModal.tsx | 778 +++++++++++++
.../TemplateList.tsx | 137 +++
.../CertificateTemplatesV2Tab/index.ts | 4 +
.../shared/components.tsx | 143 +++
.../CertificateTemplatesV2Tab/shared/index.ts | 3 +
.../shared/schemas.ts | 64 ++
.../CertificateTemplatesV2Tab/shared/utils.ts | 72 ++
.../PoliciesPage/components/index.ts | 2 +
.../pages/cert-manager/PoliciesPage/index.ts | 1 +
.../pages/cert-manager/PoliciesPage/route.tsx | 19 +
frontend/src/routeTree.gen.ts | 28 +
frontend/src/routes.ts | 1 +
57 files changed, 5271 insertions(+), 298 deletions(-)
create mode 100644 frontend/src/hooks/api/certificateProfiles/index.ts
create mode 100644 frontend/src/hooks/api/certificateProfiles/mutations.tsx
create mode 100644 frontend/src/hooks/api/certificateProfiles/queries.tsx
create mode 100644 frontend/src/hooks/api/certificateProfiles/types.ts
create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/index.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/index.ts
create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/route.tsx
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 = () => {
)}
@@ -110,7 +107,7 @@ export const PkiManagerLayout = () => {
- Certificates Authority
+ Certificates Authorities
)}
@@ -167,6 +164,48 @@ export const PkiManagerLayout = () => {
)}
+ {showLegacySection && (
+
+ {hasExistingSubscribers && (
+
+ {({ isActive }) => (
+
+ )}
+
+ )}
+ {hasExistingTemplates && (
+
+ {({ isActive }) => (
+
+ )}
+
+ )}
+
+ )}
{
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 && (
+
+ )}
+
+
+ );
+};
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) => (
- }
- onClick={() => handlePopUpOpen("pkiSubscriber")}
- isDisabled={!isAllowed}
- className="ml-4"
- >
- Add Subscriber
-
- )}
-
+ {allowNewSubscriberCreation && (
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("pkiSubscriber")}
+ isDisabled={!isAllowed}
+ className="ml-4"
+ >
+ Add Subscriber
+
+ )}
+
+ )}
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 && (
+
}
+ onClick={handleCreateProfile}
+ >
+ Create Profile
+
+ )}
+
+
+
+
+
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();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
new file mode 100644
index 000000000..7ab072a86
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
@@ -0,0 +1,303 @@
+import { useEffect, useState } from "react";
+import { faSave } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ Checkbox,
+ FormControl,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ TextArea
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
+import {
+ TCertificateProfileWithDetails,
+ useUpdateCertificateProfile
+} from "@app/hooks/api/certificateProfiles";
+import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ profile: TCertificateProfileWithDetails;
+}
+
+export const EditProfileModal = ({ isOpen, onClose, profile }: Props) => {
+ const { currentProject } = useProject();
+ const updateProfile = useUpdateCertificateProfile();
+
+ const { data: caData } = useListCasByProjectId(currentProject?.id || "");
+ const { data: templateData } = useListCertificateTemplatesV2({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0
+ });
+
+ const certificateAuthorities = caData || [];
+ const certificateTemplates = templateData?.certificateTemplates || [];
+
+ const [formData, setFormData] = useState({
+ name: "",
+ slug: "",
+ description: "",
+ enrollmentType: "api" as "api" | "est",
+ certificateAuthorityId: "",
+ certificateTemplateId: "",
+ estConfig: {
+ disableBootstrapCaValidation: false,
+ passphrase: "",
+ caChain: ""
+ },
+ apiConfig: {
+ autoRenew: false,
+ autoRenewDays: 30
+ }
+ });
+
+ useEffect(() => {
+ if (profile) {
+ setFormData({
+ name: profile.name,
+ slug: profile.slug,
+ description: profile.description || "",
+ enrollmentType: profile.enrollmentType,
+ certificateAuthorityId: profile.caId,
+ certificateTemplateId: profile.certificateTemplateId,
+ estConfig: {
+ disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
+ passphrase: "",
+ caChain: ""
+ },
+ apiConfig: {
+ autoRenew: profile.apiConfig?.autoRenew || false,
+ autoRenewDays: profile.apiConfig?.autoRenewDays || 30
+ }
+ });
+ }
+ }, [profile]);
+
+ const handleInputChange = (field: string, value: string | boolean | number) => {
+ if (field.includes(".")) {
+ const [parent, child] = field.split(".");
+ setFormData((prev) => ({
+ ...prev,
+ [parent]: {
+ ...(prev as any)[parent],
+ [child]: value
+ }
+ }));
+ } else {
+ setFormData((prev) => ({
+ ...prev,
+ [field]: value
+ }));
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!formData.name) {
+ return;
+ }
+
+ try {
+ const payload: any = {
+ profileId: profile.id,
+ name: formData.name,
+ description: formData.description
+ };
+
+ if (formData.enrollmentType === "est") {
+ payload.estConfig = {
+ disableBootstrapCaValidation: formData.estConfig.disableBootstrapCaValidation,
+ passphrase: formData.estConfig.passphrase,
+ caChain: formData.estConfig.caChain
+ };
+ } else if (formData.enrollmentType === "api") {
+ payload.apiConfig = {
+ autoRenew: formData.apiConfig.autoRenew,
+ autoRenewDays: formData.apiConfig.autoRenewDays
+ };
+ }
+
+ await updateProfile.mutateAsync(payload);
+
+ createNotification({
+ text: "Certificate profile updated successfully",
+ type: "success"
+ });
+
+ onClose();
+ } catch (error) {
+ console.error("Error updating profile:", error);
+ createNotification({
+ text: "Failed to update certificate profile",
+ type: "error"
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx
new file mode 100644
index 000000000..cccec2a0e
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx
@@ -0,0 +1,71 @@
+import {
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import {
+ TCertificateProfile,
+ useListCertificateProfiles
+} from "@app/hooks/api/certificateProfiles";
+
+import { ProfileRow } from "./ProfileRow";
+
+interface Props {
+ onEditProfile: (profile: TCertificateProfile) => void;
+ onDeleteProfile: (profile: TCertificateProfile) => void;
+}
+
+export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
+ const { currentProject } = useProject();
+
+ const { data, isLoading } = useListCertificateProfiles({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0,
+ includeMetrics: true
+ });
+
+ const profiles = data?.certificateProfiles || [];
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!profiles || profiles.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ | Name |
+ Enrollment Type |
+ Certificate Authority |
+ Template |
+ Certificates |
+ Created |
+ |
+
+
+
+ {profiles.map((profile) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx
new file mode 100644
index 000000000..d1b4cca4f
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx
@@ -0,0 +1,182 @@
+/* eslint-disable no-nested-ternary */
+import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import {
+ Badge,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Td,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { useGetCaById } from "@app/hooks/api/ca/queries";
+import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
+import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
+
+interface Props {
+ profile: TCertificateProfile;
+ onEditProfile: (profile: TCertificateProfile) => void;
+ onDeleteProfile: (profile: TCertificateProfile) => void;
+}
+
+export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => {
+ const { permission } = useProjectPermission();
+
+ const { data: caData } = useGetCaById(profile.caId);
+
+ const { data: templateData } = useGetCertificateTemplateV2ById({
+ templateId: profile.certificateTemplateId
+ });
+
+ const canEditProfile = permission.can(
+ ProjectPermissionActions.Edit,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const canDeleteProfile = permission.can(
+ ProjectPermissionActions.Delete,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const getEnrollmentTypeBadge = (enrollmentType: string) => {
+ const config = {
+ api: { variant: "success" as const, label: "API" },
+ est: { variant: "primary" as const, label: "EST" }
+ };
+
+ const { variant, label } = config[enrollmentType as keyof typeof config] || config.api;
+
+ return {label};
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString();
+ };
+
+ return (
+
+
+
+
+ {profile.name}
+ {profile.description && (
+
+
+
+ )}
+
+ {profile.slug}
+
+ |
+ {getEnrollmentTypeBadge(profile.enrollmentType)} |
+
+
+ {caData?.friendlyName || caData?.commonName || profile.caId}
+
+ |
+
+
+ {templateData?.name || profile.certificateTemplateId}
+
+ |
+
+
+ {profile.metrics ? (
+ profile.metrics.totalCertificates === 0 ? (
+ No certificates attached
+ ) : (
+ <>
+ {profile.metrics.activeCertificates > 0 && (
+
+ {profile.metrics.activeCertificates} active
+
+ )}
+ {profile.metrics.expiringCertificates > 0 && (
+ <>
+ {profile.metrics.activeCertificates > 0 && (
+ •
+ )}
+
+ {profile.metrics.expiringCertificates} expiring
+
+ >
+ )}
+ {profile.metrics.expiredCertificates > 0 && (
+ <>
+ {(profile.metrics.activeCertificates > 0 ||
+ profile.metrics.expiringCertificates > 0) && (
+ •
+ )}
+
+ {profile.metrics.expiredCertificates} expired
+
+ >
+ )}
+ {profile.metrics.revokedCertificates > 0 && (
+ <>
+ {(profile.metrics.activeCertificates > 0 ||
+ profile.metrics.expiringCertificates > 0 ||
+ profile.metrics.expiredCertificates > 0) && (
+ •
+ )}
+
+ {profile.metrics.revokedCertificates} revoked
+
+ >
+ )}
+ >
+ )
+ ) : (
+ No metrics available
+ )}
+
+ |
+
+ {formatDate(profile.createdAt)}
+ |
+
+
+
+
+
+
+
+
+
+
+ {canEditProfile && (
+ {
+ e.stopPropagation();
+ onEditProfile(profile);
+ }}
+ icon={}
+ >
+ Edit Profile
+
+ )}
+ {canDeleteProfile && (
+ {
+ e.stopPropagation();
+ onDeleteProfile(profile);
+ }}
+ icon={}
+ >
+ Delete Profile
+
+ )}
+
+
+ |
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts
new file mode 100644
index 000000000..f07d54d30
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/index.ts
@@ -0,0 +1,4 @@
+export { CertificateProfilesTab } from "./CertificateProfilesTab";
+export { CreateProfileModal } from "./CreateProfileModal";
+export { EditProfileModal } from "./EditProfileModal";
+export { ProfileList } from "./ProfileList";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx
new file mode 100644
index 000000000..cc680b1da
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx
@@ -0,0 +1,113 @@
+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 { useDeleteCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
+import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
+
+import { CreateTemplateModal } from "./CreateTemplateModal";
+import { EditTemplateModal } from "./EditTemplateModal";
+import { TemplateList } from "./TemplateList";
+
+export const CertificateTemplatesV2Tab = () => {
+ const { permission } = useProjectPermission();
+
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedTemplate, setSelectedTemplate] = useState(null);
+
+ const deleteTemplateV2 = useDeleteCertificateTemplateV2New();
+
+ const canCreateTemplate = permission.can(
+ ProjectPermissionActions.Create,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const handleCreateTemplate = () => {
+ setIsCreateModalOpen(true);
+ };
+
+ const handleEditTemplate = (template: TCertificateTemplateV2New) => {
+ setSelectedTemplate(template);
+ setIsEditModalOpen(true);
+ };
+
+ const handleDeleteTemplate = (template: TCertificateTemplateV2New) => {
+ setSelectedTemplate(template);
+ setIsDeleteModalOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!selectedTemplate) return;
+
+ try {
+ await deleteTemplateV2.mutateAsync({
+ templateId: selectedTemplate.id
+ });
+ setIsDeleteModalOpen(false);
+ setSelectedTemplate(null);
+ } catch (error) {
+ console.error("Failed to delete template:", error);
+ }
+ };
+
+ return (
+
+
+
+
Certificate Templates
+
+ Define certificate policies, validation rules, and attribute constraints for certificate
+ issuance
+
+
+
+ {canCreateTemplate && (
+
}
+ onClick={handleCreateTemplate}
+ >
+ Create Template
+
+ )}
+
+
+
+
+
setIsCreateModalOpen(false)} />
+
+ {selectedTemplate && (
+ <>
+ {
+ setIsEditModalOpen(false);
+ setSelectedTemplate(null);
+ }}
+ template={selectedTemplate}
+ />
+
+ {
+ setIsDeleteModalOpen(isOpen);
+ if (!isOpen) setSelectedTemplate(null);
+ }}
+ deleteKey={selectedTemplate.name}
+ onDeleteApproved={handleDeleteConfirm}
+ />
+ >
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
new file mode 100644
index 000000000..1ad7e43ce
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx
@@ -0,0 +1,699 @@
+import { useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { createNotification } from "@app/components/notifications";
+import {
+ Button,
+ FormControl,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ TextArea
+} from "@app/components/v2";
+import { useProject } from "@app/context";
+import { useCreateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
+
+import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
+
+export type FormData = TemplateFormData;
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const ATTRIBUTE_TYPES = [{ value: "common_name", label: "Common Name (CN)" }];
+
+const SAN_TYPES = [
+ { value: "dns_name", label: "DNS Name" },
+ { value: "ip_address", label: "IP Address" },
+ { value: "email", label: "Email" },
+ { value: "uri", label: "URI" }
+];
+
+const INCLUDE_TYPES = [
+ { value: "mandatory", label: "Mandatory", color: "red" },
+ { value: "optional", label: "Optional", color: "blue" },
+ { value: "prohibit", label: "Prohibited", color: "gray" }
+];
+
+const SIGNATURE_ALGORITHMS = [
+ "SHA256-RSA",
+ "SHA384-RSA",
+ "SHA512-RSA",
+ "SHA256-ECDSA",
+ "SHA384-ECDSA",
+ "SHA512-ECDSA"
+];
+
+const KEY_ALGORITHMS = [
+ "RSA-2048",
+ "RSA-3072",
+ "RSA-4096",
+ "ECDSA-P256",
+ "ECDSA-P384",
+ "ECDSA-P521"
+];
+
+export const CreateTemplateModal = ({ isOpen, onClose }: Props) => {
+ const { currentProject } = useProject();
+ const createTemplate = useCreateCertificateTemplateV2New();
+ const [activeTab, setActiveTab] = useState("basic");
+
+ const { control, handleSubmit, reset, watch, setValue } = useForm({
+ resolver: zodResolver(templateSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ attributes: [],
+ keyUsages: { requiredUsages: [], optionalUsages: [] },
+ extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
+ subjectAlternativeNames: [],
+ validity: {
+ maxDuration: { value: 365, unit: "days" }
+ },
+ signatureAlgorithm: {
+ allowedAlgorithms: ["SHA256-RSA"],
+ defaultAlgorithm: "SHA256-RSA"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: ["RSA-2048"],
+ defaultKeyType: "RSA-2048"
+ }
+ }
+ });
+
+ const watchedAttributes = watch("attributes") || [];
+ const watchedSans = watch("subjectAlternativeNames") || [];
+ const watchedKeyUsages = watch("keyUsages");
+ const watchedExtendedKeyUsages = watch("extendedKeyUsages");
+
+ const onFormSubmit = async (data: FormData) => {
+ try {
+ if (!currentProject?.id) return;
+
+ const templateData = {
+ projectId: currentProject.id,
+ name: data.name,
+ description: data.description,
+ attributes: data.attributes || [],
+ keyUsages: {
+ requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
+ optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
+ optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
+ },
+ subjectAlternativeNames: data.subjectAlternativeNames || [],
+ validity: {
+ maxDuration: data.validity?.maxDuration || { value: 365, unit: "days" as const }
+ },
+ signatureAlgorithm: {
+ allowedAlgorithms: data.signatureAlgorithm?.allowedAlgorithms || ["SHA256-RSA"],
+ defaultAlgorithm: data.signatureAlgorithm?.defaultAlgorithm || "SHA256-RSA"
+ },
+ keyAlgorithm: {
+ allowedKeyTypes: data.keyAlgorithm?.allowedKeyTypes || ["RSA-2048"],
+ defaultKeyType: data.keyAlgorithm?.defaultKeyType || "RSA-2048"
+ }
+ };
+
+ await createTemplate.mutateAsync(templateData);
+
+ createNotification({
+ text: "Certificate template created successfully",
+ type: "success"
+ });
+
+ reset();
+ onClose();
+ } catch (error) {
+ console.error("Error creating template:", error);
+ createNotification({
+ text: "Failed to create certificate template",
+ type: "error"
+ });
+ }
+ };
+
+ const addAttribute = () => {
+ const newAttribute = {
+ type: "common_name" as const,
+ include: "optional" as const,
+ value: []
+ };
+ setValue("attributes", [...watchedAttributes, newAttribute]);
+ };
+
+ const removeAttribute = (index: number) => {
+ const newAttributes = watchedAttributes.filter((_, i) => i !== index);
+ setValue("attributes", newAttributes);
+ };
+
+ const addSan = () => {
+ const newSan = {
+ type: "dns_name" as const,
+ include: "optional" as const,
+ value: []
+ };
+ setValue("subjectAlternativeNames", [...watchedSans, newSan]);
+ };
+
+ const removeSan = (index: number) => {
+ const newSans = watchedSans.filter((_, i) => i !== index);
+ setValue("subjectAlternativeNames", newSans);
+ };
+
+ const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
+ const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
+ const otherType = type === "required" ? "optional" : "required";
+ const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
+ const otherList = Array.isArray(current[`${otherType}Usages`])
+ ? current[`${otherType}Usages`]
+ : [];
+
+ const newOtherList = (otherList || []).filter((u) => u !== usage);
+ const newCurrentList = currentList?.includes(usage)
+ ? currentList.filter((u) => u !== usage)
+ : [...(currentList || []), usage];
+
+ setValue("keyUsages", {
+ [`${type}Usages`]: newCurrentList,
+ [`${otherType}Usages`]: newOtherList
+ } as any);
+ };
+
+ const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
+ const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
+ const otherType = type === "required" ? "optional" : "required";
+ const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
+ const otherList = Array.isArray(current[`${otherType}Usages`])
+ ? current[`${otherType}Usages`]
+ : [];
+
+ const newOtherList = (otherList || []).filter((u) => u !== usage);
+ const newCurrentList = currentList?.includes(usage)
+ ? currentList.filter((u) => u !== usage)
+ : [...(currentList || []), usage];
+
+ setValue("extendedKeyUsages", {
+ [`${type}Usages`]: newCurrentList,
+ [`${otherType}Usages`]: newOtherList
+ } as any);
+ };
+
+ const tabs = [
+ { id: "basic", label: "Basic Info" },
+ { id: "attributes", label: "Subject Attributes" },
+ { id: "san", label: "Subject Alternative Names" },
+ { id: "usages", label: "Key Usages" },
+ { id: "constraints", label: "Constraints" }
+ ];
+
+ return (
+ {
+ if (!open) {
+ reset();
+ }
+ onClose();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
new file mode 100644
index 000000000..b3ba1409c
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
@@ -0,0 +1,778 @@
+import { useEffect, useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+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 {
+ Button,
+ FormControl,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ TextArea
+} from "@app/components/v2";
+import { useUpdateCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/mutations";
+import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
+
+import { KeyUsagesSection } from "./shared";
+
+const attributeSchema = z.object({
+ type: z.enum(["common_name"]),
+ include: z.enum(["mandatory", "optional", "prohibit"]),
+ value: z.array(z.string()).optional()
+});
+
+const sanSchema = z.object({
+ type: z.enum(["dns_name", "ip_address", "email", "uri"]),
+ include: z.enum(["mandatory", "optional", "prohibit"]),
+ value: z.array(z.string()).optional()
+});
+
+const schema = z.object({
+ name: z.string().trim().min(1, "Template name is required"),
+ description: z.string().optional(),
+ attributes: z.array(attributeSchema).optional(),
+ keyUsages: z
+ .object({
+ requiredUsages: z.array(z.string()),
+ optionalUsages: z.array(z.string())
+ })
+ .optional(),
+ extendedKeyUsages: z
+ .object({
+ requiredUsages: z.array(z.string()),
+ optionalUsages: z.array(z.string())
+ })
+ .optional(),
+ subjectAlternativeNames: z.array(sanSchema).optional(),
+ validity: z
+ .object({
+ maxDuration: z.object({
+ value: z.number().positive(),
+ unit: z.enum(["days", "months", "years"])
+ }),
+ minDuration: z
+ .object({
+ value: z.number().positive(),
+ unit: z.enum(["days", "months", "years"])
+ })
+ .optional()
+ })
+ .optional(),
+ signatureAlgorithm: z
+ .object({
+ allowedAlgorithms: z.array(z.string()).min(1),
+ defaultAlgorithm: z.string()
+ })
+ .optional(),
+ keyAlgorithm: z
+ .object({
+ allowedKeyTypes: z.array(z.string()).min(1),
+ defaultKeyType: z.string()
+ })
+ .optional()
+});
+
+export type FormData = z.infer;
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+ template: TCertificateTemplateV2New;
+}
+
+const ATTRIBUTE_TYPES = [
+ { value: "common_name", label: "Common Name (CN)" }
+];
+
+const SAN_TYPES = [
+ { value: "dns_name", label: "DNS Name" },
+ { value: "ip_address", label: "IP Address" },
+ { value: "email", label: "Email" },
+ { value: "uri", label: "URI" }
+];
+
+const INCLUDE_TYPES = [
+ { value: "mandatory", label: "Mandatory", color: "red" },
+ { value: "optional", label: "Optional", color: "blue" },
+ { value: "prohibit", label: "Prohibited", color: "gray" }
+];
+
+const SIGNATURE_ALGORITHMS = [
+ "SHA256-RSA",
+ "SHA384-RSA",
+ "SHA512-RSA",
+ "SHA256-ECDSA",
+ "SHA384-ECDSA",
+ "SHA512-ECDSA"
+];
+
+const KEY_ALGORITHMS = [
+ "RSA-2048",
+ "RSA-3072",
+ "RSA-4096",
+ "ECDSA-P256",
+ "ECDSA-P384",
+ "ECDSA-P521"
+];
+
+export const EditTemplateModal = ({ isOpen, onClose, template }: Props) => {
+ const updateTemplate = useUpdateCertificateTemplateV2New();
+ const [activeTab, setActiveTab] = useState("basic");
+
+ const getFormDefaultValues = () => {
+ if (!template) {
+ return {
+ name: "",
+ description: "",
+ attributes: [],
+ keyUsages: { requiredUsages: [], optionalUsages: [] },
+ extendedKeyUsages: { requiredUsages: [], optionalUsages: [] },
+ subjectAlternativeNames: [],
+ validity: { maxDuration: { value: 365, unit: "days" as const } },
+ signatureAlgorithm: { allowedAlgorithms: ["SHA256-RSA"], defaultAlgorithm: "SHA256-RSA" },
+ keyAlgorithm: { allowedKeyTypes: ["RSA-2048"], defaultKeyType: "RSA-2048" }
+ };
+ }
+
+ const backendKeyUsages = template.keyUsages || {
+ requiredUsages: { all: [] },
+ optionalUsages: { all: [] }
+ };
+ const backendExtendedKeyUsages = template.extendedKeyUsages || {
+ requiredUsages: { all: [] },
+ optionalUsages: { all: [] }
+ };
+
+ return {
+ name: template.name,
+ description: template.description || "",
+ attributes: template.attributes || [],
+ keyUsages: {
+ requiredUsages: backendKeyUsages.requiredUsages?.all || [],
+ optionalUsages: backendKeyUsages.optionalUsages?.all || []
+ },
+ extendedKeyUsages: {
+ requiredUsages: backendExtendedKeyUsages.requiredUsages?.all || [],
+ optionalUsages: backendExtendedKeyUsages.optionalUsages?.all || []
+ },
+ subjectAlternativeNames: template.subjectAlternativeNames || [],
+ validity: template.validity || { maxDuration: { value: 365, unit: "days" as const } },
+ signatureAlgorithm: template.signatureAlgorithm || {
+ allowedAlgorithms: ["SHA256-RSA"],
+ defaultAlgorithm: "SHA256-RSA"
+ },
+ keyAlgorithm: template.keyAlgorithm || {
+ allowedKeyTypes: ["RSA-2048"],
+ defaultKeyType: "RSA-2048"
+ }
+ };
+ };
+
+ const { control, handleSubmit, reset, watch, setValue } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: getFormDefaultValues()
+ });
+
+ const watchedAttributes = watch("attributes") || [];
+ const watchedSans = watch("subjectAlternativeNames") || [];
+ const watchedKeyUsages = watch("keyUsages");
+ const watchedExtendedKeyUsages = watch("extendedKeyUsages");
+
+ useEffect(() => {
+ if (template) {
+ reset(getFormDefaultValues());
+ }
+ }, [template, reset]);
+
+ const onFormSubmit = async (data: FormData) => {
+ try {
+ const templateData = {
+ templateId: template.id,
+ name: data.name,
+ description: data.description,
+ attributes: data.attributes || [],
+ keyUsages: {
+ requiredUsages: { all: data.keyUsages?.requiredUsages || [] },
+ optionalUsages: { all: data.keyUsages?.optionalUsages || [] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: data.extendedKeyUsages?.requiredUsages || [] },
+ optionalUsages: { all: data.extendedKeyUsages?.optionalUsages || [] }
+ },
+ subjectAlternativeNames: data.subjectAlternativeNames || [],
+ validity: data.validity || {
+ maxDuration: { value: 365, unit: "days" as const }
+ },
+ signatureAlgorithm: data.signatureAlgorithm || {
+ allowedAlgorithms: ["SHA256-RSA"],
+ defaultAlgorithm: "SHA256-RSA"
+ },
+ keyAlgorithm: data.keyAlgorithm || {
+ allowedKeyTypes: ["RSA-2048"],
+ defaultKeyType: "RSA-2048"
+ }
+ };
+
+ await updateTemplate.mutateAsync(templateData);
+
+ createNotification({
+ text: "Certificate template updated successfully",
+ type: "success"
+ });
+
+ onClose();
+ } catch (error) {
+ console.error("Error updating template:", error);
+ createNotification({
+ text: "Failed to update certificate template",
+ type: "error"
+ });
+ }
+ };
+
+ const addAttribute = () => {
+ const newAttribute = {
+ type: "common_name" as const,
+ include: "optional" as const,
+ value: []
+ };
+ setValue("attributes", [...watchedAttributes, newAttribute]);
+ };
+
+ const removeAttribute = (index: number) => {
+ const newAttributes = watchedAttributes.filter((_, i) => i !== index);
+ setValue("attributes", newAttributes);
+ };
+
+ const addSan = () => {
+ const newSan = {
+ type: "dns_name" as const,
+ include: "optional" as const,
+ value: []
+ };
+ setValue("subjectAlternativeNames", [...watchedSans, newSan]);
+ };
+
+ const removeSan = (index: number) => {
+ const newSans = watchedSans.filter((_, i) => i !== index);
+ setValue("subjectAlternativeNames", newSans);
+ };
+
+ const toggleKeyUsage = (usage: string, type: "required" | "optional") => {
+ const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
+ const otherType = type === "required" ? "optional" : "required";
+ const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
+ const otherList = Array.isArray(current[`${otherType}Usages`])
+ ? current[`${otherType}Usages`]
+ : [];
+
+ const newOtherList = otherList.filter((u) => u !== usage);
+
+ const newCurrentList = currentList.includes(usage)
+ ? currentList.filter((u) => u !== usage)
+ : [...currentList, usage];
+
+ setValue("keyUsages", {
+ [`${type}Usages`]: newCurrentList,
+ [`${otherType}Usages`]: newOtherList
+ } as any);
+ };
+
+ const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => {
+ const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
+ const otherType = type === "required" ? "optional" : "required";
+ const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
+ const otherList = Array.isArray(current[`${otherType}Usages`])
+ ? current[`${otherType}Usages`]
+ : [];
+
+ const newOtherList = otherList.filter((u) => u !== usage);
+
+ const newCurrentList = currentList.includes(usage)
+ ? currentList.filter((u) => u !== usage)
+ : [...currentList, usage];
+
+ setValue("extendedKeyUsages", {
+ [`${type}Usages`]: newCurrentList,
+ [`${otherType}Usages`]: newOtherList
+ } as any);
+ };
+
+ const tabs = [
+ { id: "basic", label: "Basic Info" },
+ { id: "attributes", label: "Subject Attributes" },
+ { id: "san", label: "Subject Alternative Names" },
+ { id: "usages", label: "Key Usages" },
+ { id: "constraints", label: "Constraints" }
+ ];
+
+ return (
+ {
+ if (!open) {
+ reset();
+ }
+ onClose();
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx
new file mode 100644
index 000000000..4d9bc1356
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx
@@ -0,0 +1,137 @@
+import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ EmptyState,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tooltip,
+ Tr
+} from "@app/components/v2";
+import { useProject, useProjectPermission } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub
+} from "@app/context/ProjectPermissionContext/types";
+import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
+import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
+
+interface Props {
+ onEditTemplate: (template: TCertificateTemplateV2New) => void;
+ onDeleteTemplate: (template: TCertificateTemplateV2New) => void;
+}
+
+export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
+ const { permission } = useProjectPermission();
+ const { currentProject } = useProject();
+
+ const { data, isLoading } = useListCertificateTemplatesV2({
+ projectId: currentProject?.id || "",
+ limit: 100,
+ offset: 0
+ });
+
+ const templates = data?.certificateTemplates || [];
+
+ const canEditTemplate = permission.can(
+ ProjectPermissionActions.Edit,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const canDeleteTemplate = permission.can(
+ ProjectPermissionActions.Delete,
+ ProjectPermissionSub.CertificateAuthorities
+ );
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString();
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!templates || templates.length === 0) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ | Name |
+ Created |
+ |
+
+
+
+ {templates.map((template) => (
+
+
+
+ {template.name}
+ {template.description && (
+
+
+
+ )}
+
+ |
+
+ {formatDate(template.createdAt)}
+ |
+
+
+
+
+
+
+
+
+
+
+ {canEditTemplate && (
+ {
+ e.stopPropagation();
+ onEditTemplate(template);
+ }}
+ icon={}
+ >
+ Edit Template
+
+ )}
+ {canDeleteTemplate && (
+ {
+ e.stopPropagation();
+ onDeleteTemplate(template);
+ }}
+ icon={}
+ >
+ Delete Template
+
+ )}
+
+
+ |
+
+ ))}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts
new file mode 100644
index 000000000..f7372b94c
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/index.ts
@@ -0,0 +1,4 @@
+export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
+export { CreateTemplateModal } from "./CreateTemplateModal";
+export { EditTemplateModal } from "./EditTemplateModal";
+export { TemplateList } from "./TemplateList";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx
new file mode 100644
index 000000000..9cf6a14ed
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/components.tsx
@@ -0,0 +1,143 @@
+import { Button } from "@app/components/v2";
+
+import {
+ EXTENDED_KEY_USAGES,
+ formatUsageName,
+ getUsageState,
+ KEY_USAGES,
+ toggleUsageState
+} from "./utils";
+
+type UsageToggleProps = {
+ value: "required" | "optional" | undefined;
+ onChange: (value: "required" | "optional" | undefined) => void;
+};
+
+export const UsageToggle = ({ value, onChange }: UsageToggleProps) => {
+ return (
+
+
+
+
+ );
+};
+
+type KeyUsagesSectionProps = {
+ watchedKeyUsages?: {
+ requiredUsages?: string[];
+ optionalUsages?: string[];
+ };
+ watchedExtendedKeyUsages?: {
+ requiredUsages?: string[];
+ optionalUsages?: string[];
+ };
+ toggleKeyUsage: (usage: string, type: "required" | "optional") => void;
+ toggleExtendedKeyUsage: (usage: string, type: "required" | "optional") => void;
+};
+
+export const KeyUsagesSection = ({
+ watchedKeyUsages,
+ watchedExtendedKeyUsages,
+ toggleKeyUsage,
+ toggleExtendedKeyUsage
+}: KeyUsagesSectionProps) => {
+ return (
+
+
+
Key Usages
+
+ {KEY_USAGES.map((usage) => {
+ const requiredUsages = Array.isArray(watchedKeyUsages?.requiredUsages)
+ ? watchedKeyUsages.requiredUsages
+ : [];
+ const optionalUsages = Array.isArray(watchedKeyUsages?.optionalUsages)
+ ? watchedKeyUsages.optionalUsages
+ : [];
+
+ const currentState = getUsageState(usage, requiredUsages, optionalUsages);
+
+ return (
+
+
+ {formatUsageName(usage)}
+
+ {
+ toggleUsageState(
+ usage,
+ newValue,
+ requiredUsages,
+ optionalUsages,
+ (u) => toggleKeyUsage(u, "required"),
+ (u) => toggleKeyUsage(u, "optional")
+ );
+ }}
+ />
+
+ );
+ })}
+
+
+
+
+
Extended Key Usages
+
+ {EXTENDED_KEY_USAGES.map((usage) => {
+ const requiredUsages = Array.isArray(watchedExtendedKeyUsages?.requiredUsages)
+ ? watchedExtendedKeyUsages.requiredUsages
+ : [];
+ const optionalUsages = Array.isArray(watchedExtendedKeyUsages?.optionalUsages)
+ ? watchedExtendedKeyUsages.optionalUsages
+ : [];
+
+ const currentState = getUsageState(usage, requiredUsages, optionalUsages);
+
+ return (
+
+
+ {formatUsageName(usage)}
+
+ {
+ toggleUsageState(
+ usage,
+ newValue,
+ requiredUsages,
+ optionalUsages,
+ (u) => toggleExtendedKeyUsage(u, "required"),
+ (u) => toggleExtendedKeyUsage(u, "optional")
+ );
+ }}
+ />
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts
new file mode 100644
index 000000000..6567de724
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/index.ts
@@ -0,0 +1,3 @@
+export * from "./components";
+export * from "./schemas";
+export * from "./utils";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts
new file mode 100644
index 000000000..ff106da99
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts
@@ -0,0 +1,64 @@
+import { z } from "zod";
+
+import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./utils";
+
+export const attributeSchema = z.object({
+ type: z.enum(SUBJECT_ATTRIBUTE_TYPES),
+ include: z.enum(INCLUDE_OPTIONS),
+ value: z.array(z.string()).optional()
+});
+
+export const sanSchema = z.object({
+ type: z.enum(SAN_TYPES),
+ include: z.enum(INCLUDE_OPTIONS),
+ value: z.array(z.string()).optional()
+});
+
+export const templateSchema = z.object({
+ name: z.string().trim().min(1, "Template name is required"),
+ description: z.string().optional(),
+ attributes: z.array(attributeSchema).optional(),
+ keyUsages: z
+ .object({
+ requiredUsages: z.array(z.string()).optional(),
+ optionalUsages: z.array(z.string()).optional()
+ })
+ .optional(),
+ extendedKeyUsages: z
+ .object({
+ requiredUsages: z.array(z.string()).optional(),
+ optionalUsages: z.array(z.string()).optional()
+ })
+ .optional(),
+ subjectAlternativeNames: z.array(sanSchema).optional(),
+ validity: z
+ .object({
+ maxDuration: z
+ .object({
+ value: z.number().min(1, "Duration must be at least 1"),
+ unit: z.enum(["days", "months", "years"])
+ })
+ .optional(),
+ minDuration: z
+ .object({
+ value: z.number().min(1, "Duration must be at least 1"),
+ unit: z.enum(["days", "months", "years"])
+ })
+ .optional()
+ })
+ .optional(),
+ signatureAlgorithm: z
+ .object({
+ allowedAlgorithms: z.array(z.string()).optional(),
+ defaultAlgorithm: z.string().optional()
+ })
+ .optional(),
+ keyAlgorithm: z
+ .object({
+ allowedKeyTypes: z.array(z.string()).optional(),
+ defaultKeyType: z.string().optional()
+ })
+ .optional()
+});
+
+export type TemplateFormData = z.infer;
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts
new file mode 100644
index 000000000..2fecb2264
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/utils.ts
@@ -0,0 +1,72 @@
+export const KEY_USAGES = [
+ "digital_signature",
+ "key_encipherment",
+ "non_repudiation",
+ "data_encipherment",
+ "key_agreement",
+ "key_cert_sign",
+ "crl_sign",
+ "encipher_only",
+ "decipher_only"
+] as const;
+
+export const EXTENDED_KEY_USAGES = [
+ "client_auth",
+ "server_auth",
+ "code_signing",
+ "email_protection",
+ "ocsp_signing",
+ "time_stamping"
+] as const;
+
+export const SUBJECT_ATTRIBUTE_TYPES = [
+ "common_name",
+ "organization_name",
+ "organization_unit",
+ "locality",
+ "state",
+ "country",
+ "email",
+ "street_address",
+ "postal_code"
+] as const;
+
+export const SAN_TYPES = ["dns_name", "ip_address", "email", "uri"] as const;
+
+export const INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
+
+export const formatUsageName = (usage: string): string => {
+ return usage.replace(/_/g, " ");
+};
+export const getUsageState = (
+ usage: string,
+ requiredUsages: string[],
+ optionalUsages: string[]
+): "required" | "optional" | undefined => {
+ if (requiredUsages.includes(usage)) return "required";
+ if (optionalUsages.includes(usage)) return "optional";
+ return undefined;
+};
+
+export const toggleUsageState = (
+ usage: string,
+ newState: "required" | "optional" | undefined,
+ currentRequiredUsages: string[],
+ currentOptionalUsages: string[],
+ toggleRequired: (usage: string) => void,
+ toggleOptional: (usage: string) => void
+) => {
+ const isRequired = currentRequiredUsages.includes(usage);
+ const isOptional = currentOptionalUsages.includes(usage);
+
+ if (newState === "required") {
+ if (isOptional) toggleOptional(usage);
+ if (!isRequired) toggleRequired(usage);
+ } else if (newState === "optional") {
+ if (isRequired) toggleRequired(usage);
+ if (!isOptional) toggleOptional(usage);
+ } else {
+ if (isRequired) toggleRequired(usage);
+ if (isOptional) toggleOptional(usage);
+ }
+};
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts
new file mode 100644
index 000000000..62809f571
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts
@@ -0,0 +1,2 @@
+export { CertificateProfilesTab } from "./CertificateProfilesTab";
+export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/index.ts
new file mode 100644
index 000000000..e5a4decb2
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/index.ts
@@ -0,0 +1 @@
+export { PoliciesPage } from "./PoliciesPage";
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx
new file mode 100644
index 000000000..1db3b90d9
--- /dev/null
+++ b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx
@@ -0,0 +1,19 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+import { PoliciesPage } from "./PoliciesPage";
+
+export const Route = createFileRoute(
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies"
+)({
+ component: PoliciesPage,
+ beforeLoad: ({ context }) => {
+ return {
+ breadcrumbs: [
+ ...context.breadcrumbs,
+ {
+ label: "Certificate Policies"
+ }
+ ]
+ };
+ }
+});
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
index 1562f302d..277baa912 100644
--- a/frontend/src/routeTree.gen.ts
+++ b/frontend/src/routeTree.gen.ts
@@ -114,6 +114,7 @@ import { Route as kmsSettingsPageRouteImport } from './pages/kms/SettingsPage/ro
import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/route'
import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route'
import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route'
+import { Route as certManagerPoliciesPageRouteImport } from './pages/cert-manager/PoliciesPage/route'
import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route'
import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route'
import { Route as certManagerAlertingPageRouteImport } from './pages/cert-manager/AlertingPage/route'
@@ -1216,6 +1217,13 @@ const certManagerSettingsPageRouteRoute =
getParentRoute: () => certManagerLayoutRoute,
} as any)
+const certManagerPoliciesPageRouteRoute =
+ certManagerPoliciesPageRouteImport.update({
+ id: '/policies',
+ path: '/policies',
+ getParentRoute: () => certManagerLayoutRoute,
+ } as any)
+
const certManagerCertificatesPageRouteRoute =
certManagerCertificatesPageRouteImport.update({
id: '/certificates',
@@ -2820,6 +2828,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof certManagerCertificatesPageRouteImport
parentRoute: typeof certManagerLayoutImport
}
+ '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': {
+ id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies'
+ path: '/policies'
+ fullPath: '/projects/cert-management/$projectId/policies'
+ preLoaderRoute: typeof certManagerPoliciesPageRouteImport
+ parentRoute: typeof certManagerLayoutImport
+ }
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': {
id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings'
path: '/settings'
@@ -4165,6 +4180,7 @@ interface certManagerLayoutRouteChildren {
certManagerAlertingPageRouteRoute: typeof certManagerAlertingPageRouteRoute
certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute
certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute
+ certManagerPoliciesPageRouteRoute: typeof certManagerPoliciesPageRouteRoute
certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute
projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute
projectAppConnectionsPageRouteCertManagerRoute: typeof projectAppConnectionsPageRouteCertManagerRoute
@@ -4185,6 +4201,7 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = {
certManagerCertificateAuthoritiesPageRouteRoute:
certManagerCertificateAuthoritiesPageRouteRoute,
certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute,
+ certManagerPoliciesPageRouteRoute: certManagerPoliciesPageRouteRoute,
certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute,
projectAccessControlPageRouteCertManagerRoute:
projectAccessControlPageRouteCertManagerRoute,
@@ -5100,6 +5117,7 @@ export interface FileRoutesByFullPath {
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
@@ -5333,6 +5351,7 @@ export interface FileRoutesByTo {
'/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute
'/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute
'/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute
'/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute
'/projects/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute
@@ -5578,6 +5597,7 @@ export interface FileRoutesById {
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute
+ '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': typeof certManagerPoliciesPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute
'/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute
@@ -5821,6 +5841,7 @@ export interface FileRouteTypes {
| '/projects/cert-management/$projectId/alerting'
| '/projects/cert-management/$projectId/certificate-authorities'
| '/projects/cert-management/$projectId/certificates'
+ | '/projects/cert-management/$projectId/policies'
| '/projects/cert-management/$projectId/settings'
| '/projects/kms/$projectId/kmip'
| '/projects/kms/$projectId/overview'
@@ -6053,6 +6074,7 @@ export interface FileRouteTypes {
| '/projects/cert-management/$projectId/alerting'
| '/projects/cert-management/$projectId/certificate-authorities'
| '/projects/cert-management/$projectId/certificates'
+ | '/projects/cert-management/$projectId/policies'
| '/projects/cert-management/$projectId/settings'
| '/projects/kms/$projectId/kmip'
| '/projects/kms/$projectId/overview'
@@ -6296,6 +6318,7 @@ export interface FileRouteTypes {
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates'
+ | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies'
| '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings'
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip'
| '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/overview'
@@ -6943,6 +6966,7 @@ export const routeTree = rootRoute
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates",
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/access-management",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/app-connections",
@@ -7060,6 +7084,10 @@ export const routeTree = rootRoute
"filePath": "cert-manager/CertificatesPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
},
+ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies": {
+ "filePath": "cert-manager/PoliciesPage/route.tsx",
+ "parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
+ },
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings": {
"filePath": "cert-manager/SettingsPage/route.tsx",
"parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout"
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index bebc50339..860c677c1 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -301,6 +301,7 @@ const secretManagerIntegrationsRedirect = route("/integrations", [
const certManagerRoutes = route("/projects/cert-management/$projectId", [
layout("cert-manager-layout", "cert-manager/layout.tsx", [
+ route("/policies", "cert-manager/PoliciesPage/route.tsx"),
route("/subscribers", [
index("cert-manager/PkiSubscribersPage/route.tsx"),
route("/$subscriberName", "cert-manager/PkiSubscriberDetailsByIDPage/route.tsx")
From ef9a1961f1ac11173b1488eb832bebcfe17f829e Mon Sep 17 00:00:00 2001
From: Carlos Monastyrski
Date: Fri, 10 Oct 2025 03:14:24 -0300
Subject: [PATCH 004/148] Minor fix on keys
---
.../server/routes/v3/certificates-router.ts | 69 ++++++++--------
.../certificate-common/certificate-utils.ts | 3 +-
.../certificate-template-v2-service.test.ts | 25 ++++++
.../certificate-template-v2-service.ts | 1 +
.../certificate-v3-service.test.ts | 80 +++++++++++++++++++
5 files changed, 144 insertions(+), 34 deletions(-)
diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts
index 7aa19f900..568d418da 100644
--- a/backend/src/server/routes/v3/certificates-router.ts
+++ b/backend/src/server/routes/v3/certificates-router.ts
@@ -12,6 +12,7 @@ import {
validateAndMapAltNameType,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";
+import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
@@ -49,44 +50,48 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
+ const rawCertificateRequest = {
+ commonName: req.body.commonName,
+ keyUsages: req.body.keyUsages,
+ extendedKeyUsages: req.body.extendedKeyUsages,
+ subjectAlternativeNames: req.body.altNames
+ ? req.body.altNames
+ .split(", ")
+ .map((name) => name.trim())
+ .map((name) => {
+ const mappedType = validateAndMapAltNameType(name);
+ if (!mappedType) return null;
+ const typeMapping = {
+ dns: "dns_name",
+ ip: "ip_address",
+ email: "email",
+ url: "uri"
+ } as const;
+ return {
+ type: typeMapping[mappedType.type] as "dns_name" | "ip_address" | "email" | "uri",
+ value: mappedType.value
+ };
+ })
+ .filter((item): item is NonNullable => item !== null)
+ : undefined,
+ validity: {
+ ttl: req.body.ttl
+ },
+ notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
+ notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
+ signatureAlgorithm: req.body.signatureAlgorithm,
+ keyAlgorithm: req.body.keyAlgorithm
+ };
+
+ const mappedCertificateRequest = mapEnumsForValidation(rawCertificateRequest);
+
const data = await server.services.certificateV3.issueCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId,
- certificateRequest: {
- commonName: req.body.commonName,
- keyUsages: req.body.keyUsages,
- extendedKeyUsages: req.body.extendedKeyUsages,
- subjectAlternativeNames: req.body.altNames
- ? req.body.altNames
- .split(", ")
- .map((name) => name.trim())
- .map((name) => {
- const mappedType = validateAndMapAltNameType(name);
- if (!mappedType) return null;
- const typeMapping = {
- dns: "dns_name",
- ip: "ip_address",
- email: "email",
- url: "uri"
- } as const;
- return {
- type: typeMapping[mappedType.type] as "dns_name" | "ip_address" | "email" | "uri",
- value: mappedType.value
- };
- })
- .filter((item): item is NonNullable => item !== null)
- : undefined,
- validity: {
- ttl: req.body.ttl
- },
- notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
- notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
- signatureAlgorithm: req.body.signatureAlgorithm,
- keyAlgorithm: req.body.keyAlgorithm
- }
+ certificateRequest: mappedCertificateRequest
});
const profile = await server.services.certificateProfile.getProfileById({
diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts
index 0287450d0..492fc0543 100644
--- a/backend/src/services/certificate-common/certificate-utils.ts
+++ b/backend/src/services/certificate-common/certificate-utils.ts
@@ -1,7 +1,6 @@
interface CertificateRequestInput {
keyUsages?: string[];
extendedKeyUsages?: string[];
- [key: string]: unknown;
}
export const mapEnumsForValidation = (request: T): T => {
@@ -12,7 +11,7 @@ export const mapEnumsForValidation = (request
dataEncipherment: "data_encipherment",
keyAgreement: "key_agreement",
keyCertSign: "key_cert_sign",
- crlSign: "crl_sign",
+ cRLSign: "crl_sign",
encipherOnly: "encipher_only",
decipherOnly: "decipher_only"
};
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 cb3d7614f..c578c0ae0 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
@@ -608,6 +608,31 @@ describe("CertificateTemplateV2Service", () => {
expect(result.isValid).toBe(true);
});
+ it("should handle camelCase key usage mapping correctly", async () => {
+ const templateWithOptionalUsages = {
+ ...sampleTemplate,
+ keyUsages: {
+ requiredUsages: { all: ["digital_signature", "non_repudiation", "key_agreement"] },
+ optionalUsages: { all: ["crl_sign", "decipher_only"] }
+ },
+ extendedKeyUsages: {
+ requiredUsages: { all: ["client_auth", "code_signing"] },
+ optionalUsages: { all: ["server_auth", "ocsp_signing"] }
+ }
+ };
+ mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages);
+
+ const requestWithCamelCaseUsages = {
+ ...validRequest,
+ keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"],
+ extendedKeyUsages: ["client_auth", "code_signing"]
+ };
+
+ const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages);
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
it("should validate wildcard patterns in optional attributes", async () => {
const wildcardTemplate = {
...sampleTemplate,
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 7aaf9028e..d098e4a42 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
@@ -120,6 +120,7 @@ export const certificateTemplateV2ServiceFactory = ({
request: TCertificateRequest
): TTemplateValidationResult => {
const errors: string[] = [];
+
const warnings: string[] = [];
template.attributes?.forEach((attrPolicy) => {
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 dfd4acce3..ce07621a7 100644
--- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
@@ -141,6 +141,86 @@ describe("CertificateV3Service", () => {
expect(result).toHaveProperty("certificateId", "cert-123");
});
+ it("should correctly map camelCase key usages to snake_case before validation", async () => {
+ const profileId = "profile-123";
+ const mockProfile = {
+ id: profileId,
+ projectId: "project-123",
+ enrollmentType: EnrollmentType.API,
+ caId: "ca-123",
+ certificateTemplateId: "template-123"
+ };
+
+ const mockCA = {
+ id: "ca-123",
+ externalCa: null
+ };
+
+ const mockTemplate = {
+ id: "template-123",
+ signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
+ keyAlgorithm: { defaultKeyType: "RSA_2048" },
+ attributes: []
+ };
+
+ const mockCertificateResult = {
+ certificate: Buffer.from("cert"),
+ certificateChain: Buffer.from("chain"),
+ privateKey: Buffer.from("key"),
+ serialNumber: "123456"
+ };
+
+ const mockCertRecord = {
+ id: "cert-123",
+ serialNumber: "123456"
+ };
+
+ const camelCaseRequest = {
+ commonName: "test.example.com",
+ keyUsages: [
+ CertKeyUsage.DIGITAL_SIGNATURE,
+ CertKeyUsage.NON_REPUDIATION,
+ CertKeyUsage.KEY_AGREEMENT,
+ CertKeyUsage.CRL_SIGN,
+ CertKeyUsage.DECIPHER_ONLY
+ ],
+ extendedKeyUsages: [
+ CertExtendedKeyUsage.CLIENT_AUTH,
+ CertExtendedKeyUsage.CODE_SIGNING,
+ CertExtendedKeyUsage.OCSP_SIGNING,
+ CertExtendedKeyUsage.SERVER_AUTH
+ ],
+ validity: { ttl: "10d" }
+ };
+
+ mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
+ mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
+ isValid: true,
+ errors: [],
+ warnings: []
+ });
+ mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(mockCA);
+ mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(mockTemplate);
+ mockInternalCaService.issueCertFromCa.mockResolvedValue(mockCertificateResult);
+ mockCertificateDAL.findOne.mockResolvedValue(mockCertRecord);
+ mockCertificateDAL.updateById.mockResolvedValue({});
+
+ await service.issueCertificateFromProfile({
+ profileId,
+ certificateRequest: camelCaseRequest,
+ ...mockActor
+ });
+
+ // Verify that the template validation service was called with mapped snake_case values
+ expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
+ "template-123",
+ expect.objectContaining({
+ keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"],
+ extendedKeyUsages: ["client_auth", "code_signing", "ocsp_signing", "server_auth"]
+ })
+ );
+ });
+
it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
const profileId = "profile-123";
const mockProfile = {
From 8905885254cab5ee5c1f32ff6c2fc908244aff29 Mon Sep 17 00:00:00 2001
From: Carlos Monastyrski
Date: Fri, 10 Oct 2025 19:49:15 -0300
Subject: [PATCH 005/148] PKI revamp: UI improvements
---
backend/src/@types/knex.d.ts | 28 +-
.../20251007133321_pki-v3-tables.ts | 39 +-
.../src/db/schemas/certificate-profiles.ts | 1 -
.../db/schemas/certificate-templates-v2.ts | 2 +-
backend/src/db/schemas/index.ts | 4 +-
backend/src/db/schemas/models.ts | 4 +-
...nfigs.ts => pki-api-enrollment-configs.ts} | 10 +-
...nfigs.ts => pki-est-enrollment-configs.ts} | 10 +-
.../ee/services/audit-log/audit-log-types.ts | 84 +-
.../ee/services/permission/default-roles.ts | 1 +
.../routes/v1/certificate-profiles-router.ts | 17 +-
.../routes/v1/certificate-template-router.ts | 19 +-
.../v2/certificate-templates-v2-router.ts | 20 +-
.../server/routes/v3/certificates-router.ts | 8 +-
.../certificate-profile-dal.ts | 44 +-
.../certificate-profile-schemas.ts | 2 -
.../certificate-profile-service.test.ts | 40 +-
.../certificate-profile-service.ts | 23 +-
.../certificate-profile-types.ts | 9 +
.../certificate-template-v2-dal.ts | 12 +-
.../certificate-template-v2-schemas.ts | 19 +-
.../certificate-template-v2-service.test.ts | 10 +-
.../certificate-template-v2-service.ts | 74 +-
.../certificate-template-v2-types.ts | 2 +-
.../certificate-v3-service.test.ts | 6 +-
.../certificate-v3/certificate-v3-service.ts | 20 +-
.../certificate-v3/certificate-v3-types.ts | 4 +-
.../api-enrollment-config-dal.ts | 20 +-
.../enrollment-config-types.ts | 28 +-
.../est-enrollment-config-dal.ts | 10 +-
.../hooks/api/certificateProfiles/types.ts | 6 +-
.../hooks/api/certificateTemplates/types.ts | 6 +-
.../components/CertificateIssuanceModal.tsx | 163 +--
.../CertificateProfilesTab.tsx | 8 +-
.../CreateProfileModal.tsx | 232 ++--
.../EditProfileModal.tsx | 303 -----
.../CertificateProfilesTab/ProfileList.tsx | 3 +-
.../CertificateProfilesTab/ProfileRow.tsx | 76 +-
.../CertificateProfilesTab/index.ts | 1 -
.../CertificateTemplatesV2Tab.tsx | 8 +-
.../CreateTemplateModal.tsx | 1086 +++++++++--------
.../EditTemplateModal.tsx | 778 ------------
.../TemplateList.tsx | 2 +-
.../CertificateTemplatesV2Tab/index.ts | 1 -
.../shared/schemas.ts | 2 +-
45 files changed, 1156 insertions(+), 2089 deletions(-)
rename backend/src/db/schemas/{api-enrollment-configs.ts => pki-api-enrollment-configs.ts} (53%)
rename backend/src/db/schemas/{est-enrollment-configs.ts => pki-est-enrollment-configs.ts} (57%)
delete mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/EditProfileModal.tsx
delete mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/EditTemplateModal.tsx
diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts
index 318c6d67b..ef731f0c2 100644
--- a/backend/src/@types/knex.d.ts
+++ b/backend/src/@types/knex.d.ts
@@ -17,9 +17,9 @@ import {
TAccessApprovalRequestsReviewersInsert,
TAccessApprovalRequestsReviewersUpdate,
TAccessApprovalRequestsUpdate,
- TApiEnrollmentConfigs,
- TApiEnrollmentConfigsInsert,
- TApiEnrollmentConfigsUpdate,
+ TPkiApiEnrollmentConfigs,
+ TPkiApiEnrollmentConfigsInsert,
+ TPkiApiEnrollmentConfigsUpdate,
TApiKeys,
TApiKeysInsert,
TApiKeysUpdate,
@@ -80,9 +80,9 @@ import {
TDynamicSecrets,
TDynamicSecretsInsert,
TDynamicSecretsUpdate,
- TEstEnrollmentConfigs,
- TEstEnrollmentConfigsInsert,
- TEstEnrollmentConfigsUpdate,
+ TPkiEstEnrollmentConfigs,
+ TPkiEstEnrollmentConfigsInsert,
+ TPkiEstEnrollmentConfigsUpdate,
TExternalCertificateAuthorities,
TExternalCertificateAuthoritiesInsert,
TExternalCertificateAuthoritiesUpdate,
@@ -678,15 +678,15 @@ declare module "knex/types/tables" {
TCertificateProfilesInsert,
TCertificateProfilesUpdate
>;
- [TableName.EstEnrollmentConfig]: KnexOriginal.CompositeTableType<
- TEstEnrollmentConfigs,
- TEstEnrollmentConfigsInsert,
- TEstEnrollmentConfigsUpdate
+ [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType<
+ TPkiEstEnrollmentConfigs,
+ TPkiEstEnrollmentConfigsInsert,
+ TPkiEstEnrollmentConfigsUpdate
>;
- [TableName.ApiEnrollmentConfig]: KnexOriginal.CompositeTableType<
- TApiEnrollmentConfigs,
- TApiEnrollmentConfigsInsert,
- TApiEnrollmentConfigsUpdate
+ [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType<
+ TPkiApiEnrollmentConfigs,
+ TPkiApiEnrollmentConfigsInsert,
+ TPkiApiEnrollmentConfigsUpdate
>;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs,
diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts
index d8943405c..90b727f68 100644
--- a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts
+++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts
@@ -10,7 +10,7 @@ export async function up(knex: Knex): Promise {
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
- t.string("name", 64).notNullable();
+ t.string("slug").notNullable();
t.string("description");
t.jsonb("attributes");
@@ -22,13 +22,15 @@ export async function up(knex: Knex): Promise {
t.jsonb("keyAlgorithm");
t.timestamps(true, true, true);
+
+ t.unique(["slug", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
}
- if (!(await knex.schema.hasTable(TableName.EstEnrollmentConfig))) {
- await knex.schema.createTable(TableName.EstEnrollmentConfig, (t) => {
+ if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) {
+ await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("disableBootstrapCaValidation").defaultTo(false);
@@ -38,11 +40,11 @@ export async function up(knex: Knex): Promise {
t.timestamps(true, true, true);
});
- await createOnUpdateTrigger(knex, TableName.EstEnrollmentConfig);
+ await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
}
- if (!(await knex.schema.hasTable(TableName.ApiEnrollmentConfig))) {
- await knex.schema.createTable(TableName.ApiEnrollmentConfig, (t) => {
+ if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) {
+ await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("autoRenew").defaultTo(false);
@@ -51,7 +53,7 @@ export async function up(knex: Knex): Promise {
t.timestamps(true, true, true);
});
- await createOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig);
+ await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
}
if (!(await knex.schema.hasTable(TableName.CertificateProfile))) {
@@ -61,25 +63,24 @@ export async function up(knex: Knex): Promise {
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("caId").notNullable();
- t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE");
+ t.foreign("caId").references("id").inTable(TableName.CertificateAuthority);
t.uuid("certificateTemplateId").notNullable();
- t.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplateV2).onDelete("CASCADE");
+ t.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplateV2);
- t.string("name", 64).notNullable();
t.string("slug").notNullable();
t.string("description");
t.string("enrollmentType").notNullable().checkIn(["api", "est"]);
t.uuid("estConfigId");
- t.foreign("estConfigId").references("id").inTable(TableName.EstEnrollmentConfig).onDelete("SET NULL");
+ t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL");
t.uuid("apiConfigId");
- t.foreign("apiConfigId").references("id").inTable(TableName.ApiEnrollmentConfig).onDelete("SET NULL");
+ t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL");
t.timestamps(true, true, true);
- t.unique(["slug", "projectId"], { indexName: "certificate_profiles_slug_project_id_unique" });
+ t.unique(["slug", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.CertificateProfile);
@@ -89,7 +90,7 @@ export async function up(knex: Knex): Promise {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.uuid("profileId");
t.foreign("profileId").references("id").inTable(TableName.CertificateProfile).onDelete("SET NULL");
- t.index("profileId", "idx_certificates_profile_id");
+ t.index("profileId");
});
}
}
@@ -98,7 +99,7 @@ export async function down(knex: Knex): Promise {
if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.dropForeign(["profileId"]);
- t.dropIndex("profileId", "idx_certificates_profile_id");
+ t.dropIndex("profileId");
t.dropColumn("profileId");
});
}
@@ -106,11 +107,11 @@ export async function down(knex: Knex): Promise {
await knex.schema.dropTableIfExists(TableName.CertificateProfile);
await dropOnUpdateTrigger(knex, TableName.CertificateProfile);
- await knex.schema.dropTableIfExists(TableName.ApiEnrollmentConfig);
- await dropOnUpdateTrigger(knex, TableName.ApiEnrollmentConfig);
+ await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig);
+ await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
- await knex.schema.dropTableIfExists(TableName.EstEnrollmentConfig);
- await dropOnUpdateTrigger(knex, TableName.EstEnrollmentConfig);
+ await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig);
+ await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
await knex.schema.dropTableIfExists(TableName.CertificateTemplateV2);
await dropOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
diff --git a/backend/src/db/schemas/certificate-profiles.ts b/backend/src/db/schemas/certificate-profiles.ts
index 888bb1996..9e91706ed 100644
--- a/backend/src/db/schemas/certificate-profiles.ts
+++ b/backend/src/db/schemas/certificate-profiles.ts
@@ -12,7 +12,6 @@ export const CertificateProfilesSchema = z.object({
projectId: z.string(),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
- name: z.string(),
slug: z.string(),
description: z.string().nullable().optional(),
enrollmentType: z.string(),
diff --git a/backend/src/db/schemas/certificate-templates-v2.ts b/backend/src/db/schemas/certificate-templates-v2.ts
index 0d91fb8da..68c3c3ca4 100644
--- a/backend/src/db/schemas/certificate-templates-v2.ts
+++ b/backend/src/db/schemas/certificate-templates-v2.ts
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplatesV2Schema = z.object({
id: z.string().uuid(),
projectId: z.string(),
- name: z.string(),
+ slug: z.string(),
description: z.string().nullable().optional(),
attributes: z.unknown().nullable().optional(),
keyUsages: z.unknown().nullable().optional(),
diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts
index fec41f3e2..bdc8f9883 100644
--- a/backend/src/db/schemas/index.ts
+++ b/backend/src/db/schemas/index.ts
@@ -3,7 +3,6 @@ export * from "./access-approval-policies-approvers";
export * from "./access-approval-policies-bypassers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
-export * from "./api-enrollment-configs";
export * from "./api-keys";
export * from "./app-connections";
export * from "./audit-log-streams";
@@ -24,7 +23,6 @@ export * from "./certificate-templates-v2";
export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";
-export * from "./est-enrollment-configs";
export * from "./external-certificate-authorities";
export * from "./external-group-org-role-mappings";
export * from "./external-kms";
@@ -92,8 +90,10 @@ export * from "./pam-folders";
export * from "./pam-resources";
export * from "./pam-sessions";
export * from "./pki-alerts";
+export * from "./pki-api-enrollment-configs";
export * from "./pki-collection-items";
export * from "./pki-collections";
+export * from "./pki-est-enrollment-configs";
export * from "./pki-subscribers";
export * from "./pki-syncs";
export * from "./project-bots";
diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts
index 20baaa3c0..b3d1addd3 100644
--- a/backend/src/db/schemas/models.ts
+++ b/backend/src/db/schemas/models.ts
@@ -25,8 +25,8 @@ export enum TableName {
CertificateTemplate = "certificate_templates",
CertificateTemplateV2 = "certificate_templates_v2",
CertificateProfile = "certificate_profiles",
- EstEnrollmentConfig = "est_enrollment_configs",
- ApiEnrollmentConfig = "api_enrollment_configs",
+ PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
+ PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts",
PkiCollection = "pki_collections",
diff --git a/backend/src/db/schemas/api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts
similarity index 53%
rename from backend/src/db/schemas/api-enrollment-configs.ts
rename to backend/src/db/schemas/pki-api-enrollment-configs.ts
index 37fdfc163..710b0dee4 100644
--- a/backend/src/db/schemas/api-enrollment-configs.ts
+++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts
@@ -7,7 +7,7 @@ import { z } from "zod";
import { TImmutableDBKeys } from "./models";
-export const ApiEnrollmentConfigsSchema = z.object({
+export const PkiApiEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
autoRenew: z.boolean().default(false).nullable().optional(),
autoRenewDays: z.number().nullable().optional(),
@@ -15,6 +15,8 @@ export const ApiEnrollmentConfigsSchema = z.object({
updatedAt: z.date()
});
-export type TApiEnrollmentConfigs = z.infer;
-export type TApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
-export type TApiEnrollmentConfigsUpdate = Partial, TImmutableDBKeys>>;
+export type TPkiApiEnrollmentConfigs = z.infer;
+export type TPkiApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
+export type TPkiApiEnrollmentConfigsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/db/schemas/est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts
similarity index 57%
rename from backend/src/db/schemas/est-enrollment-configs.ts
rename to backend/src/db/schemas/pki-est-enrollment-configs.ts
index 8e60d58f4..f54eb0561 100644
--- a/backend/src/db/schemas/est-enrollment-configs.ts
+++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts
@@ -9,7 +9,7 @@ import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
-export const EstEnrollmentConfigsSchema = z.object({
+export const PkiEstEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(),
hashedPassphrase: z.string(),
@@ -18,6 +18,8 @@ export const EstEnrollmentConfigsSchema = z.object({
updatedAt: z.date()
});
-export type TEstEnrollmentConfigs = z.infer;
-export type TEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
-export type TEstEnrollmentConfigsUpdate = Partial, TImmutableDBKeys>>;
+export type TPkiEstEnrollmentConfigs = z.infer;
+export type TPkiEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>;
+export type TPkiEstEnrollmentConfigsUpdate = Partial<
+ Omit, TImmutableDBKeys>
+>;
diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts
index 547967bdd..cb63cd0f7 100644
--- a/backend/src/ee/services/audit-log/audit-log-types.ts
+++ b/backend/src/ee/services/audit-log/audit-log-types.ts
@@ -352,14 +352,10 @@ export enum EventType {
UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template",
DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template",
GET_CERTIFICATE_TEMPLATE = "get-certificate-template",
+ LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates",
CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config",
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
- CREATE_CERTIFICATE_TEMPLATE_V2 = "create-certificate-template-v2",
- UPDATE_CERTIFICATE_TEMPLATE_V2 = "update-certificate-template-v2",
- DELETE_CERTIFICATE_TEMPLATE_V2 = "delete-certificate-template-v2",
- GET_CERTIFICATE_TEMPLATE_V2 = "get-certificate-template-v2",
- LIST_CERTIFICATE_TEMPLATES_V2 = "list-certificate-templates-v2",
CREATE_CERTIFICATE_PROFILE = "create-certificate-profile",
UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile",
DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile",
@@ -2525,46 +2521,6 @@ interface LoadProjectKmsBackupEvent {
metadata: Record; // no metadata yet
}
-interface CreateCertificateTemplate {
- type: EventType.CREATE_CERTIFICATE_TEMPLATE;
- metadata: {
- certificateTemplateId: string;
- caId: string;
- pkiCollectionId?: string;
- name: string;
- commonName: string;
- subjectAlternativeName: string;
- ttl: string;
- };
-}
-
-interface GetCertificateTemplate {
- type: EventType.GET_CERTIFICATE_TEMPLATE;
- metadata: {
- certificateTemplateId: string;
- };
-}
-
-interface UpdateCertificateTemplate {
- type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
- metadata: {
- certificateTemplateId: string;
- caId: string;
- pkiCollectionId?: string;
- name: string;
- commonName: string;
- subjectAlternativeName: string;
- ttl: string;
- };
-}
-
-interface DeleteCertificateTemplate {
- type: EventType.DELETE_CERTIFICATE_TEMPLATE;
- metadata: {
- certificateTemplateId: string;
- };
-}
-
interface OrgAdminAccessProjectEvent {
type: EventType.ORG_ADMIN_ACCESS_PROJECT;
metadata: {
@@ -2611,8 +2567,8 @@ interface GetCertificateTemplateEstConfig {
};
}
-interface CreateCertificateTemplateV2 {
- type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2;
+interface CreateCertificateTemplate {
+ type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
name: string;
@@ -2620,30 +2576,32 @@ interface CreateCertificateTemplateV2 {
};
}
-interface UpdateCertificateTemplateV2 {
- type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2;
+interface UpdateCertificateTemplate {
+ type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
name: string;
};
}
-interface DeleteCertificateTemplateV2 {
- type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2;
+interface DeleteCertificateTemplate {
+ type: EventType.DELETE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
+ name: string;
};
}
-interface GetCertificateTemplateV2 {
- type: EventType.GET_CERTIFICATE_TEMPLATE_V2;
+interface GetCertificateTemplate {
+ type: EventType.GET_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
+ name: string;
};
}
-interface ListCertificateTemplatesV2 {
- type: EventType.LIST_CERTIFICATE_TEMPLATES_V2;
+interface ListCertificateTemplates {
+ type: EventType.LIST_CERTIFICATE_TEMPLATES;
metadata: {
projectId: string;
};
@@ -2710,7 +2668,7 @@ interface OrderCertificateFromProfile {
metadata: {
certificateProfileId: string;
orderId: string;
- identifiers: string[];
+ subjectAlternativeNames: string[];
};
}
@@ -4167,18 +4125,14 @@ export type Event =
| LoadProjectKmsBackupEvent
| OrgAdminAccessProjectEvent
| OrgAdminBypassSSOEvent
- | CreateCertificateTemplate
- | UpdateCertificateTemplate
- | GetCertificateTemplate
- | DeleteCertificateTemplate
| CreateCertificateTemplateEstConfig
| UpdateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig
- | CreateCertificateTemplateV2
- | UpdateCertificateTemplateV2
- | DeleteCertificateTemplateV2
- | GetCertificateTemplateV2
- | ListCertificateTemplatesV2
+ | CreateCertificateTemplate
+ | UpdateCertificateTemplate
+ | DeleteCertificateTemplate
+ | GetCertificateTemplate
+ | ListCertificateTemplates
| CreateCertificateProfile
| UpdateCertificateProfile
| DeleteCertificateProfile
diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts
index 973d088f1..34876f739 100644
--- a/backend/src/ee/services/permission/default-roles.ts
+++ b/backend/src/ee/services/permission/default-roles.ts
@@ -455,6 +455,7 @@ const buildMemberPermissionRules = () => {
// double check if all CRUD are needed for CA and Certificates
can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities);
+ can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
can(
[
diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts
index 98e8c30a6..70b1cf830 100644
--- a/backend/src/server/routes/v1/certificate-profiles-router.ts
+++ b/backend/src/server/routes/v1/certificate-profiles-router.ts
@@ -9,7 +9,6 @@ import { AuthMode } from "@app/services/auth/auth-type";
import {
createCertificateProfileSchema,
deleteCertificateProfileSchema,
- getCertificateProfileByIdSchema,
listCertificateProfilesSchema,
updateCertificateProfileSchema
} from "@app/services/certificate-profile/certificate-profile-schemas";
@@ -49,7 +48,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
type: EventType.CREATE_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
- name: certificateProfile.name,
+ name: certificateProfile.slug,
projectId: certificateProfile.projectId,
enrollmentType: certificateProfile.enrollmentType
}
@@ -125,7 +124,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
- params: getCertificateProfileByIdSchema,
+ params: z.object({
+ id: z.string().min(1)
+ }),
querystring: z.object({
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
@@ -262,7 +263,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
- params: getCertificateProfileByIdSchema,
+ params: z.object({
+ id: z.string().min(1)
+ }),
body: updateCertificateProfileSchema,
response: {
200: z.object({
@@ -288,7 +291,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
- name: certificateProfile.name
+ name: certificateProfile.slug
}
}
});
@@ -347,7 +350,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
- params: getCertificateProfileByIdSchema,
+ params: z.object({
+ id: z.string().min(1)
+ }),
querystring: z.object({
offset: z.number().min(0).default(0),
limit: z.number().min(1).max(100).default(20),
diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts
index 17f564be4..cd615b673 100644
--- a/backend/src/server/routes/v1/certificate-template-router.ts
+++ b/backend/src/server/routes/v1/certificate-template-router.ts
@@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: {
type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
- certificateTemplateId: certificateTemplate.id
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.name
}
}
});
@@ -116,12 +117,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
- caId: certificateTemplate.caId,
- pkiCollectionId: certificateTemplate.pkiCollectionId as string,
name: certificateTemplate.name,
- commonName: certificateTemplate.commonName,
- subjectAlternativeName: certificateTemplate.subjectAlternativeName,
- ttl: certificateTemplate.ttl
+ projectId: certificateTemplate.projectId
}
}
});
@@ -184,12 +181,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
- caId: certificateTemplate.caId,
- pkiCollectionId: certificateTemplate.pkiCollectionId as string,
- name: certificateTemplate.name,
- commonName: certificateTemplate.commonName,
- subjectAlternativeName: certificateTemplate.subjectAlternativeName,
- ttl: certificateTemplate.ttl
+ name: certificateTemplate.name
}
}
});
@@ -230,7 +222,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: {
type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
- certificateTemplateId: certificateTemplate.id
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.name
}
}
});
diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts
index a33a4b91c..6cf26800d 100644
--- a/backend/src/server/routes/v2/certificate-templates-v2-router.ts
+++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts
@@ -48,10 +48,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo,
projectId,
event: {
- type: EventType.CREATE_CERTIFICATE_TEMPLATE_V2,
+ type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
- name: certificateTemplate.name,
+ name: certificateTemplate.slug,
projectId: certificateTemplate.projectId
}
}
@@ -92,7 +92,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
- type: EventType.LIST_CERTIFICATE_TEMPLATES_V2,
+ type: EventType.LIST_CERTIFICATE_TEMPLATES,
metadata: {
projectId: req.query.projectId
}
@@ -133,9 +133,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
- type: EventType.GET_CERTIFICATE_TEMPLATE_V2,
+ type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
- certificateTemplateId: certificateTemplate.id
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.slug
}
}
});
@@ -176,10 +177,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
- type: EventType.UPDATE_CERTIFICATE_TEMPLATE_V2,
+ type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
- name: certificateTemplate.name
+ name: certificateTemplate.slug
}
}
});
@@ -218,9 +219,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
- type: EventType.DELETE_CERTIFICATE_TEMPLATE_V2,
+ type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
- certificateTemplateId: certificateTemplate.id
+ certificateTemplateId: certificateTemplate.id,
+ name: certificateTemplate.slug
}
}
});
diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts
index 568d418da..19fa131b6 100644
--- a/backend/src/server/routes/v3/certificates-router.ts
+++ b/backend/src/server/routes/v3/certificates-router.ts
@@ -196,7 +196,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
tags: [ApiDocsTags.PkiCertificates],
body: z.object({
profileId: z.string().uuid(),
- identifiers: z
+ subjectAlternativeNames: z
.array(
z.object({
type: z.enum(["dns", "ip"]),
@@ -217,7 +217,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
200: z.object({
orderId: z.string(),
status: z.enum(["pending", "processing", "valid", "invalid"]),
- identifiers: z.array(
+ subjectAlternativeNames: z.array(
z.object({
type: z.enum(["dns", "ip"]),
value: z.string(),
@@ -256,7 +256,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId,
profileId: req.body.profileId,
certificateOrder: {
- identifiers: req.body.identifiers,
+ subjectAlternativeNames: req.body.subjectAlternativeNames,
validity: {
ttl: req.body.ttl
},
@@ -286,7 +286,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
metadata: {
certificateProfileId: req.body.profileId,
orderId: data.orderId,
- identifiers: req.body.identifiers.map((id) => `${id.type}:${id.value}`)
+ subjectAlternativeNames: req.body.subjectAlternativeNames.map((san) => `${san.type}:${san.value}`)
}
}
});
diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts
index 2f692fba5..e8892c20c 100644
--- a/backend/src/services/certificate-profile/certificate-profile-dal.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts
@@ -68,18 +68,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
(tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
(tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
(tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"),
- (tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"),
+ (tx || db).ref("slug").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
- (tx || db).ref("id").withSchema(TableName.EstEnrollmentConfig).as("estConfigId"),
+ (tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
(tx || db)
.ref("disableBootstrapCaValidation")
- .withSchema(TableName.EstEnrollmentConfig)
+ .withSchema(TableName.PkiEstEnrollmentConfig)
.as("estConfigDisableBootstrapCaValidation"),
- (tx || db).ref("hashedPassphrase").withSchema(TableName.EstEnrollmentConfig).as("estConfigHashedPassphrase"),
- (tx || db).ref("encryptedCaChain").withSchema(TableName.EstEnrollmentConfig).as("estConfigEncryptedCaChain"),
- (tx || db).ref("id").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigId"),
- (tx || db).ref("autoRenew").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenew"),
- (tx || db).ref("autoRenewDays").withSchema(TableName.ApiEnrollmentConfig).as("apiConfigAutoRenewDays")
+ (tx || db)
+ .ref("hashedPassphrase")
+ .withSchema(TableName.PkiEstEnrollmentConfig)
+ .as("estConfigHashedPassphrase"),
+ (tx || db)
+ .ref("encryptedCaChain")
+ .withSchema(TableName.PkiEstEnrollmentConfig)
+ .as("estConfigEncryptedCaChain"),
+ (tx || db).ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
+ (tx || db).ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
+ (tx || db).ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays")
)
.leftJoin(
TableName.CertificateAuthority,
@@ -92,14 +98,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
`${TableName.CertificateTemplateV2}.id`
)
.leftJoin(
- TableName.EstEnrollmentConfig,
+ TableName.PkiEstEnrollmentConfig,
`${TableName.CertificateProfile}.estConfigId`,
- `${TableName.EstEnrollmentConfig}.id`
+ `${TableName.PkiEstEnrollmentConfig}.id`
)
.leftJoin(
- TableName.ApiEnrollmentConfig,
+ TableName.PkiApiEnrollmentConfig,
`${TableName.CertificateProfile}.apiConfigId`,
- `${TableName.ApiEnrollmentConfig}.id`
+ `${TableName.PkiApiEnrollmentConfig}.id`
)
.where(`${TableName.CertificateProfile}.id`, id)
.first();
@@ -151,9 +157,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
void builder
- .whereILike(`${TableName.CertificateProfile}.name`, `%${search}%`)
- .orWhereILike(`${TableName.CertificateProfile}.description`, `%${search}%`)
- .orWhereILike(`${TableName.CertificateProfile}.slug`, `%${search}%`);
+ .whereILike(`${TableName.CertificateProfile}.slug`, `%${search}%`)
+ .orWhereILike(`${TableName.CertificateProfile}.description`, `%${search}%`);
});
}
@@ -225,10 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
- void builder
- .whereILike("name", `%${search}%`)
- .orWhereILike("description", `%${search}%`)
- .orWhereILike("slug", `%${search}%`);
+ void builder.orWhereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`);
});
}
@@ -249,7 +251,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
try {
- const certificateProfile = await (tx || db)(TableName.CertificateProfile).where({ name, projectId }).first();
+ const certificateProfile = await (tx || db)(TableName.CertificateProfile)
+ .where({ slug: name, projectId })
+ .first();
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by name and project id" });
diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts
index 978990d69..c8aac56de 100644
--- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts
@@ -8,7 +8,6 @@ export const createCertificateProfileSchema = z
projectId: z.string().min(1),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
- name: z.string().min(1).max(255),
slug: z
.string()
.min(1)
@@ -46,7 +45,6 @@ export const createCertificateProfileSchema = z
);
export const updateCertificateProfileSchema = z.object({
- name: z.string().min(1).max(255).optional(),
slug: z
.string()
.min(1)
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 0f527bf8f..2f72f559c 100644
--- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts
@@ -80,7 +80,6 @@ describe("CertificateProfileService", () => {
const sampleProfile: TCertificateProfile = {
id: "profile-123",
projectId: "project-123",
- name: "Test Profile",
description: "Test certificate profile",
slug: "test-profile",
enrollmentType: EnrollmentType.API,
@@ -174,9 +173,8 @@ describe("CertificateProfileService", () => {
describe("createProfile", () => {
const validProfileData = {
- name: "New Profile",
- description: "New test profile",
slug: "new-profile",
+ description: "New test profile",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -204,9 +202,8 @@ describe("CertificateProfileService", () => {
expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123");
expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith({
- name: "New Profile",
- description: "New test profile",
slug: "new-profile",
+ description: "New test profile",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -273,9 +270,8 @@ describe("CertificateProfileService", () => {
it("should throw ForbiddenRequestError for API enrollment without API config", async () => {
const invalidData = {
- name: "Invalid Profile",
- description: "Invalid test profile",
slug: "invalid-profile",
+ description: "Invalid test profile",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123"
@@ -292,9 +288,8 @@ describe("CertificateProfileService", () => {
it("should create profile with API enrollment", async () => {
const apiProfileData = {
- name: "API Profile",
- description: "Profile with API enrollment",
slug: "api-profile",
+ description: "Profile with API enrollment",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -317,7 +312,7 @@ describe("CertificateProfileService", () => {
describe("updateProfile", () => {
const updateData = {
- name: "Updated Profile",
+ slug: "updated-profile",
description: "Updated description"
};
@@ -333,7 +328,7 @@ describe("CertificateProfileService", () => {
data: updateData
});
- expect(result.name).toBe("Updated Profile");
+ expect(result.slug).toBe("updated-profile");
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData);
});
@@ -697,9 +692,8 @@ describe("CertificateProfileService", () => {
describe("profile configuration validation", () => {
it("should validate EST enrollment configuration", async () => {
const estProfileData = {
- name: "EST Profile",
- description: "Profile with EST enrollment",
slug: "est-profile",
+ description: "Profile with EST enrollment",
enrollmentType: EnrollmentType.EST,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -737,9 +731,8 @@ describe("CertificateProfileService", () => {
vi.clearAllMocks();
const duplicateSlugData = {
- name: "Different Profile Name",
+ slug: "different-profile-name",
description: "Profile with duplicate slug",
- slug: "test-profile",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -763,9 +756,8 @@ describe("CertificateProfileService", () => {
it("should validate auto-renewal configuration", async () => {
const autoRenewData = {
- name: "Auto Renew Profile",
+ slug: "auto-renew-profile",
description: "Profile with auto-renewal",
- slug: "auto-renew",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -987,9 +979,8 @@ describe("CertificateProfileService", () => {
it("should handle invalid template reference during profile creation", async () => {
const profileData = {
- name: "Invalid Template Profile",
+ slug: "invalid-template-profile",
description: "Profile with invalid template",
- slug: "invalid-template",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "nonexistent-template",
@@ -1013,9 +1004,8 @@ describe("CertificateProfileService", () => {
it("should handle concurrent profile creation conflicts", async () => {
const conflictingData = {
- name: "Concurrent Profile",
- description: "Profile created concurrently",
slug: "concurrent-profile",
+ description: "Profile created concurrently",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
@@ -1042,9 +1032,8 @@ describe("CertificateProfileService", () => {
describe("permission and security", () => {
it("should validate project ownership for cross-project template access", async () => {
const crossProjectData = {
- name: "Cross Project Profile",
+ slug: "cross-project-profile",
description: "Profile using template from different project",
- slug: "cross-project",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-456",
@@ -1056,7 +1045,7 @@ describe("CertificateProfileService", () => {
const foreignTemplate = {
id: "template-456",
projectId: "different-project-456",
- name: "Foreign Template"
+ slug: "foreign-template"
};
(mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate);
@@ -1072,9 +1061,8 @@ describe("CertificateProfileService", () => {
it("should validate slug format constraints", async () => {
const invalidSlugData = {
- name: "Invalid Slug Profile",
+ slug: "invalid-slug-profile",
description: "Profile with invalid slug format",
- slug: "Invalid_Slug_With_Underscores_And_Caps",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts
index 45b073db0..6937b9bcf 100644
--- a/backend/src/services/certificate-profile/certificate-profile-service.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-service.ts
@@ -277,7 +277,28 @@ export const certificateProfileServiceFactory = ({
});
}
- const updatedProfile = await certificateProfileDAL.updateById(profileId, data);
+ const { estConfig, apiConfig, ...profileUpdateData } = data;
+
+ if (estConfig && existingProfile.estConfigId) {
+ await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, {
+ disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation,
+ ...(estConfig.passphrase && {
+ hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS)
+ }),
+ ...(estConfig.caChain && {
+ encryptedCaChain: Buffer.from(estConfig.caChain, "base64")
+ })
+ });
+ }
+
+ if (apiConfig && existingProfile.apiConfigId) {
+ await apiEnrollmentConfigDAL.updateById(existingProfile.apiConfigId, {
+ autoRenew: apiConfig.autoRenew,
+ autoRenewDays: apiConfig.autoRenewDays
+ });
+ }
+
+ const updatedProfile = await certificateProfileDAL.updateById(profileId, profileUpdateData);
return convertDalToService(updatedProfile);
};
diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts
index 2cf70878c..6ed601b23 100644
--- a/backend/src/services/certificate-profile/certificate-profile-types.ts
+++ b/backend/src/services/certificate-profile/certificate-profile-types.ts
@@ -19,6 +19,15 @@ export type TCertificateProfileInsert = Omit & {
enrollmentType?: EnrollmentType;
+ estConfig?: {
+ disableBootstrapCaValidation?: boolean;
+ passphrase?: string;
+ caChain?: string;
+ };
+ apiConfig?: {
+ autoRenew?: boolean;
+ autoRenewDays?: number;
+ };
};
export type TCertificateProfileWithConfigs = TCertificateProfile & {
diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
index a80258264..5307308b8 100644
--- a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
+++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts
@@ -139,7 +139,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
- void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
+ void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -165,7 +165,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
- void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
+ void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -176,10 +176,10 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
}
};
- const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
+ const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => {
try {
const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
- .where({ name, projectId })
+ .where({ slug, projectId })
.first();
if (!certificateTemplateV2) {
@@ -188,7 +188,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
return parseJsonFields(certificateTemplateV2);
} catch (error) {
- throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" });
+ throw new DatabaseError({ error, name: "Find certificate template v2 by slug and project id" });
}
};
@@ -213,7 +213,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
findById,
findByProjectId,
countByProjectId,
- findByNameAndProjectId,
+ findBySlugAndProjectId,
isTemplateInUse
};
};
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 068f58dcb..cfbf516f6 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,3 +1,4 @@
+import RE2 from "re2";
import { z } from "zod";
const attributeTypeSchema = z.enum(["common_name"]);
@@ -87,7 +88,11 @@ export const templateV2KeyAlgorithmSchema = z.object({
export const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1),
- name: z.string().min(1).max(255),
+ slug: z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -99,7 +104,12 @@ export const createCertificateTemplateV2Schema = z.object({
});
export const updateCertificateTemplateV2Schema = z.object({
- name: z.string().min(1).max(255).optional(),
+ slug: z
+ .string()
+ .min(1)
+ .max(255)
+ .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
+ .optional(),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -114,6 +124,11 @@ export const getCertificateTemplateV2ByIdSchema = z.object({
id: z.string().uuid()
});
+export const getCertificateTemplateV2BySlugSchema = z.object({
+ projectId: z.string().min(1),
+ slug: z.string().min(1)
+});
+
export const listCertificateTemplatesV2Schema = z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
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 c578c0ae0..f99c8eed9 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
@@ -27,6 +27,7 @@ describe("CertificateTemplateV2Service", () => {
let service: TCertificateTemplateV2ServiceFactory;
const mockCertificateTemplateV2DAL = {
+ findBySlugAndProjectId: vi.fn(),
create: vi.fn(),
findById: vi.fn(),
updateById: vi.fn(),
@@ -99,7 +100,7 @@ describe("CertificateTemplateV2Service", () => {
const sampleTemplate: TCertificateTemplateV2 = {
id: "template-123",
projectId: "project-123",
- name: "Web Server Template",
+ slug: "web-server-template",
description: "Template for web server certificates",
...samplePolicy,
createdAt: new Date(),
@@ -136,6 +137,7 @@ describe("CertificateTemplateV2Service", () => {
});
mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null);
+ mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null);
service = certificateTemplateV2ServiceFactory({
certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory,
@@ -149,7 +151,7 @@ describe("CertificateTemplateV2Service", () => {
describe("createTemplateV2", () => {
const createData: Omit = {
- name: "Test Template",
+ slug: "test-template",
description: "Test description",
...samplePolicy
};
@@ -239,7 +241,7 @@ describe("CertificateTemplateV2Service", () => {
describe("updateTemplateV2", () => {
it("should update template with valid data", async () => {
- const updateData = { name: "Updated Template Name" };
+ const updateData = { slug: "updated-template-name" };
const updatedTemplate = { ...sampleTemplate, ...updateData };
mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate);
@@ -263,7 +265,7 @@ describe("CertificateTemplateV2Service", () => {
service.updateTemplateV2({
...mockActor,
templateId: "nonexistent-template",
- data: { name: "Updated Name" }
+ data: { slug: "updated-name" }
})
).rejects.toThrow(NotFoundError);
});
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 d098e4a42..727641ea7 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
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@casl/ability";
+import slugify from "@sindresorhus/slugify";
import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas";
@@ -8,6 +9,7 @@ import {
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
+import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
@@ -115,6 +117,28 @@ export const certificateTemplateV2ServiceFactory = ({
);
};
+ const generateTemplateSlug = (baseSlug?: string): string => {
+ if (baseSlug) {
+ return slugify(baseSlug);
+ }
+ return slugify(alphaNumericNanoId(12));
+ };
+
+ const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => {
+ const existingTemplate = await certificateTemplateV2DAL.findBySlugAndProjectId(desiredSlug, projectId);
+ if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
+ return desiredSlug;
+ }
+ const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
+ const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId);
+ if (!existingAlternative) {
+ return alternativeSlug;
+ }
+
+ const randomSlug = slugify(alphaNumericNanoId(12));
+ return randomSlug;
+ };
+
const validateRequestAgainstPolicy = (
template: TCertificateTemplateV2,
request: TCertificateRequest
@@ -361,8 +385,12 @@ export const certificateTemplateV2ServiceFactory = ({
keyAlgorithm: data.keyAlgorithm
});
+ const slug = data.slug || generateTemplateSlug();
+ const uniqueSlug = await ensureUniqueSlug(projectId, slug);
+
const template = await certificateTemplateV2DAL.create({
...data,
+ slug: uniqueSlug,
projectId
});
@@ -417,7 +445,13 @@ export const certificateTemplateV2ServiceFactory = ({
validateTemplatePolicy(mergedPolicy);
}
- const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, data);
+ const updateData = { ...data };
+ if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) {
+ const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId);
+ updateData.slug = uniqueSlug;
+ }
+
+ const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData);
if (!updatedTemplate) {
throw new NotFoundError({ message: "Failed to update certificate template" });
}
@@ -459,6 +493,43 @@ export const certificateTemplateV2ServiceFactory = ({
return template;
};
+ const getTemplateV2BySlug = async ({
+ actor,
+ actorId,
+ actorAuthMethod,
+ actorOrgId,
+ projectId,
+ slug
+ }: {
+ actor: ActorType;
+ actorId: string;
+ actorAuthMethod: ActorAuthMethod;
+ actorOrgId: string;
+ projectId: string;
+ slug: string;
+ }): Promise => {
+ const { permission } = await permissionService.getProjectPermission({
+ actor,
+ actorId,
+ projectId,
+ actorAuthMethod,
+ actorOrgId,
+ actionProjectType: ActionProjectType.CertificateManager
+ });
+
+ ForbiddenError.from(permission).throwUnlessCan(
+ ProjectPermissionPkiTemplateActions.Read,
+ ProjectPermissionSub.CertificateTemplates
+ );
+
+ const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId);
+ if (!template) {
+ throw new NotFoundError({ message: "Certificate template not found" });
+ }
+
+ return template;
+ };
+
const listTemplatesV2 = async ({
actor,
actorId,
@@ -571,6 +642,7 @@ export const certificateTemplateV2ServiceFactory = ({
createTemplateV2,
updateTemplateV2,
getTemplateV2ById,
+ getTemplateV2BySlug,
listTemplatesV2,
deleteTemplateV2,
validateCertificateRequest
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 cf9f52425..17cd96642 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
@@ -74,7 +74,7 @@ export type TCertificateTemplateV2Insert = Omit<
export type TCertificateTemplateV2Update = Partial<
Pick<
TCertificateTemplateV2,
- | "name"
+ | "slug"
| "description"
| "attributes"
| "keyUsages"
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 ce07621a7..b078bde1a 100644
--- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts
@@ -347,7 +347,7 @@ describe("CertificateV3Service", () => {
describe("orderCertificateFromProfile", () => {
const mockCertificateOrder = {
- identifiers: [{ type: "dns" as const, value: "example.com" }],
+ subjectAlternativeNames: [{ type: "dns" as const, value: "example.com" }],
validity: { ttl: "30d" },
commonName: "example.com",
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE],
@@ -411,8 +411,8 @@ describe("CertificateV3Service", () => {
expect(result).toHaveProperty("orderId");
expect(result).toHaveProperty("status", "valid");
expect(result).toHaveProperty("certificate");
- expect(result.identifiers).toHaveLength(1);
- expect(result.identifiers[0]).toEqual({
+ expect(result.subjectAlternativeNames).toHaveLength(1);
+ expect(result.subjectAlternativeNames[0]).toEqual({
type: "dns",
value: "example.com",
status: "valid"
diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts
index 32ba7b958..cdf98c215 100644
--- a/backend/src/services/certificate-v3/certificate-v3-service.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-service.ts
@@ -293,9 +293,9 @@ export const certificateV3ServiceFactory = ({
commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages,
- subjectAlternativeNames: certificateOrder.identifiers.map((id) => ({
- type: id.type === "dns" ? ("dns_name" as const) : ("ip_address" as const),
- value: id.value
+ subjectAlternativeNames: certificateOrder.subjectAlternativeNames.map((san) => ({
+ type: san.type === "dns" ? ("dns_name" as const) : ("ip_address" as const),
+ value: san.value
})),
validity: certificateOrder.validity,
notBefore: certificateOrder.notBefore,
@@ -334,16 +334,16 @@ export const certificateV3ServiceFactory = ({
});
const orderId = randomUUID();
- const identifiers = certificateOrder.identifiers.map((id) => ({
- type: id.type,
- value: id.value,
+ const subjectAlternativeNames = certificateOrder.subjectAlternativeNames.map((san) => ({
+ type: san.type,
+ value: san.value,
status: "valid" as const
}));
- const authorizations = certificateOrder.identifiers.map((id) => ({
+ const authorizations = certificateOrder.subjectAlternativeNames.map((san) => ({
identifier: {
- type: id.type,
- value: id.value
+ type: san.type,
+ value: san.value
},
status: "valid" as const,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
@@ -360,7 +360,7 @@ export const certificateV3ServiceFactory = ({
return {
orderId,
status: "valid",
- identifiers,
+ subjectAlternativeNames,
authorizations,
finalize: `/api/v3/certificates/orders/${orderId}/finalize`,
certificate: certificateResult.certificate
diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts
index bd0ec2e4d..c1d69b2f5 100644
--- a/backend/src/services/certificate-v3/certificate-v3-types.ts
+++ b/backend/src/services/certificate-v3/certificate-v3-types.ts
@@ -35,7 +35,7 @@ export type TSignCertificateFromProfileDTO = {
export type TOrderCertificateFromProfileDTO = {
profileId: string;
certificateOrder: {
- identifiers: Array<{
+ subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
}>;
@@ -64,7 +64,7 @@ export type TCertificateFromProfileResponse = {
export type TCertificateOrderResponse = {
orderId: string;
status: "pending" | "processing" | "valid" | "invalid";
- identifiers: Array<{
+ subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
status: "pending" | "processing" | "valid" | "invalid";
diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts
index 558b5deda..d5b7f9c9c 100644
--- a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts
+++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts
@@ -10,11 +10,11 @@ import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enroll
export type TApiEnrollmentConfigDALFactory = ReturnType;
export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
- const apiEnrollmentConfigOrm = ormify(db, TableName.ApiEnrollmentConfig);
+ const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig);
const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => {
try {
- const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).insert(data).returning("*");
+ const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*");
return apiConfig;
} catch (error) {
@@ -24,7 +24,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => {
try {
- const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).update(data).returning("*");
+ const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*");
return apiConfig;
} catch (error) {
@@ -34,7 +34,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const deleteById = async (id: string, tx?: Knex) => {
try {
- const [apiConfig] = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).del().returning("*");
+ const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*");
return apiConfig;
} catch (error) {
@@ -44,7 +44,7 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => {
try {
- const apiConfig = await (tx || db)(TableName.ApiEnrollmentConfig).where({ id }).first();
+ const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first();
return apiConfig;
} catch (error) {
@@ -60,15 +60,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const profiles = await (tx || db)(TableName.CertificateProfile)
.join(
- TableName.ApiEnrollmentConfig,
+ TableName.PkiApiEnrollmentConfig,
`${TableName.CertificateProfile}.apiConfigId`,
- `${TableName.ApiEnrollmentConfig}.id`
+ `${TableName.PkiApiEnrollmentConfig}.id`
)
- .where(`${TableName.ApiEnrollmentConfig}.autoRenew`, true)
+ .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true)
.where((query) => {
void query
- .whereNull(`${TableName.ApiEnrollmentConfig}.autoRenewDays`)
- .orWhere(`${TableName.ApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
+ .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`)
+ .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
})
.select((tx || db).ref("id").withSchema(TableName.CertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.CertificateProfile))
diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts
index 1581f114c..329d98278 100644
--- a/backend/src/services/enrollment-config/enrollment-config-types.ts
+++ b/backend/src/services/enrollment-config/enrollment-config-types.ts
@@ -1,21 +1,21 @@
import {
- TApiEnrollmentConfigs,
- TApiEnrollmentConfigsInsert,
- TApiEnrollmentConfigsUpdate
-} from "@app/db/schemas/api-enrollment-configs";
+ TPkiApiEnrollmentConfigs,
+ TPkiApiEnrollmentConfigsInsert,
+ TPkiApiEnrollmentConfigsUpdate
+} from "@app/db/schemas/pki-api-enrollment-configs";
import {
- TEstEnrollmentConfigs,
- TEstEnrollmentConfigsInsert,
- TEstEnrollmentConfigsUpdate
-} from "@app/db/schemas/est-enrollment-configs";
+ TPkiEstEnrollmentConfigs,
+ TPkiEstEnrollmentConfigsInsert,
+ TPkiEstEnrollmentConfigsUpdate
+} from "@app/db/schemas/pki-est-enrollment-configs";
-export type TEstEnrollmentConfig = TEstEnrollmentConfigs;
-export type TEstEnrollmentConfigInsert = TEstEnrollmentConfigsInsert;
-export type TEstEnrollmentConfigUpdate = TEstEnrollmentConfigsUpdate;
+export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs;
+export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert;
+export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate;
-export type TApiEnrollmentConfig = TApiEnrollmentConfigs;
-export type TApiEnrollmentConfigInsert = TApiEnrollmentConfigsInsert;
-export type TApiEnrollmentConfigUpdate = TApiEnrollmentConfigsUpdate;
+export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs;
+export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert;
+export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
export interface TEstConfigData {
disableBootstrapCaValidation: boolean;
diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts
index 0da507225..b63fb0316 100644
--- a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts
+++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts
@@ -10,11 +10,11 @@ import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enroll
export type TEstEnrollmentConfigDALFactory = ReturnType;
export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
- const estEnrollmentConfigOrm = ormify(db, TableName.EstEnrollmentConfig);
+ const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig);
const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => {
try {
- const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).insert(data).returning("*");
+ const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*");
return estConfig;
} catch (error) {
@@ -24,7 +24,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => {
try {
- const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).update(data).returning("*");
+ const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*");
return estConfig;
} catch (error) {
@@ -34,7 +34,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const deleteById = async (id: string, tx?: Knex) => {
try {
- const [estConfig] = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).del().returning("*");
+ const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).del().returning("*");
return estConfig;
} catch (error) {
@@ -44,7 +44,7 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex) => {
try {
- const estConfig = await (tx || db)(TableName.EstEnrollmentConfig).where({ id }).first();
+ const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first();
return estConfig;
} catch (error) {
diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts
index 05307eb9a..e49a89cc7 100644
--- a/frontend/src/hooks/api/certificateProfiles/types.ts
+++ b/frontend/src/hooks/api/certificateProfiles/types.ts
@@ -3,7 +3,6 @@ export type TCertificateProfile = {
projectId: string;
caId: string;
certificateTemplateId: string;
- name: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
@@ -24,7 +23,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateTemplate?: {
id: string;
projectId: string;
- name: string;
+ slug: string;
description?: string;
};
estConfig?: {
@@ -44,7 +43,6 @@ export type TCreateCertificateProfileDTO = {
projectId: string;
caId: string;
certificateTemplateId: string;
- name: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
@@ -61,7 +59,7 @@ export type TCreateCertificateProfileDTO = {
export type TUpdateCertificateProfileDTO = {
profileId: string;
- name?: string;
+ slug?: string;
description?: string;
estConfig?: {
disableBootstrapCaValidation?: boolean;
diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts
index f6ad1c8cb..a02856a0f 100644
--- a/frontend/src/hooks/api/certificateTemplates/types.ts
+++ b/frontend/src/hooks/api/certificateTemplates/types.ts
@@ -167,7 +167,7 @@ export type TCertificateTemplateV2Policy = {
export type TCertificateTemplateV2New = {
id: string;
projectId: string;
- name: string;
+ slug: string;
description?: string;
attributes: any;
keyUsages: any;
@@ -182,7 +182,7 @@ export type TCertificateTemplateV2New = {
export type TCreateCertificateTemplateV2NewDTO = {
projectId: string;
- name: string;
+ slug: string;
description?: string;
attributes: TCertificateTemplateV2Policy["attributes"];
keyUsages: TCertificateTemplateV2Policy["keyUsages"];
@@ -195,7 +195,7 @@ export type TCreateCertificateTemplateV2NewDTO = {
export type TUpdateCertificateTemplateV2NewDTO = {
templateId: string;
- name?: string;
+ slug?: string;
description?: string;
attributes?: TCertificateTemplateV2Policy["attributes"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
index b17e394a3..ab020e27e 100644
--- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
+++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx
@@ -15,6 +15,7 @@ import {
AccordionItem,
AccordionTrigger,
Button,
+ Checkbox,
FormControl,
FormLabel,
IconButton,
@@ -26,7 +27,7 @@ import {
Tooltip
} from "@app/components/v2";
import { useProject } from "@app/context";
-import { useCreateCertificateV3, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api";
+import { useCreateCertificateV3, useGetCert } from "@app/hooks/api";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import {
certKeyAlgorithms,
@@ -44,53 +45,15 @@ 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")
+ value: z.string().min(1, "Value is required"),
+ include: z.enum(["mandatory", "optional", "prohibit"]).optional()
})
)
.min(1, "At least one subject attribute is required"),
@@ -98,7 +61,8 @@ const schema = z.object({
.array(
z.object({
type: z.enum(["dns", "ip", "email", "uri"]),
- value: z.string().min(1, "Value is required")
+ value: z.string().min(1, "Value is required"),
+ include: z.enum(["mandatory", "optional", "prohibit"]).optional()
})
)
.default([]),
@@ -159,10 +123,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
includeMetrics: false
});
- const { data: collectionsData } = useListWorkspacePkiCollections({
- projectId: currentProject?.id || ""
- });
-
const { mutateAsync: createCertificate } = useCreateCertificateV3();
const {
@@ -461,7 +421,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
const onFormSubmit = async ({
profileId,
friendlyName,
- collectionId,
subjectAttributes,
altNames,
ttl,
@@ -481,7 +440,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
profileId,
projectSlug: currentProject.slug,
- pkiCollectionId: collectionId,
friendlyName,
commonName: getAttributeValue("common_name"),
altNames: altNames
@@ -628,34 +586,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
>
{profilesData?.certificateProfiles?.map((profile) => (
- {profile.name}
-
- ))}
-
-
- )}
- />
-
- (
-
-
@@ -701,10 +632,25 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
onChange(newValue);
}}
className="w-48"
- position="popper"
>
Common Name
+
{
@@ -769,14 +715,29 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
};
onChange(newValue);
}}
- className="w-32"
- position="popper"
+ className="w-24"
>
DNS
IP
Email
URI
+
{
@@ -908,11 +869,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
-
+
Key Usages
-
+
{KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedKeyUsages.length === 0) return true;
const templateToEnumMap = {
@@ -933,15 +894,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
(
-
- {label}
-
+ field.onChange(checked)}
+ />
+
)}
@@ -954,7 +918,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
Extended Key Usages
-
+
{EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedExtendedKeyUsages.length === 0) return true;
const templateToEnumMap = {
@@ -973,15 +937,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle }: Props) =>
(
-
- {label}
-
+ field.onChange(checked)}
+ />
+
)}
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
index 54f5115df..5f8923da9 100644
--- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx
@@ -14,7 +14,6 @@ import {
} from "@app/hooks/api/certificateProfiles";
import { CreateProfileModal } from "./CreateProfileModal";
-import { EditProfileModal } from "./EditProfileModal";
import { ProfileList } from "./ProfileList";
export const CertificateProfilesTab = () => {
@@ -89,23 +88,24 @@ export const CertificateProfilesTab = () => {
{selectedProfile && (
<>
- {
setIsEditModalOpen(false);
setSelectedProfile(null);
}}
profile={selectedProfile}
+ mode="edit"
/>
{
setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedProfile(null);
}}
- deleteKey={selectedProfile.name}
+ deleteKey={selectedProfile.slug}
onDeleteApproved={handleDeleteConfirm}
/>
>
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
index 5d28e8787..fc24b8c03 100644
--- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
+++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx
@@ -1,5 +1,3 @@
-/* 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";
@@ -18,12 +16,15 @@ import {
} 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 {
+ TCertificateProfileWithDetails,
+ useCreateCertificateProfile,
+ useUpdateCertificateProfile
+} from "@app/hooks/api/certificateProfiles";
import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries";
-const schema = z
+const createSchema = 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"]),
@@ -58,14 +59,52 @@ const schema = z
}
);
-export type FormData = z.infer;
+const editSchema = z
+ .object({
+ slug: z.string().trim().min(1, "Profile slug is required"),
+ description: z.string().optional(),
+ enrollmentType: z.enum(["api", "est"]),
+ certificateAuthorityId: z.string().optional(),
+ certificateTemplateId: z.string().optional(),
+ estConfig: z
+ .object({
+ disableBootstrapCaValidation: z.boolean().optional(),
+ passphrase: z.string().optional(),
+ caChain: z.string().optional()
+ })
+ .optional(),
+ apiConfig: z
+ .object({
+ autoRenew: z.boolean().optional(),
+ autoRenewDays: z.number().min(1).max(365).optional()
+ })
+ .optional()
+ })
+ .refine(
+ (data) => {
+ if (data.enrollmentType === "est" && !data.estConfig) {
+ return false;
+ }
+ if (data.enrollmentType === "api" && !data.apiConfig) {
+ return false;
+ }
+ return true;
+ },
+ {
+ message: "Configuration is required for selected enrollment type"
+ }
+ );
+
+export type FormData = z.infer;
interface Props {
isOpen: boolean;
onClose: () => void;
+ profile?: TCertificateProfileWithDetails;
+ mode?: "create" | "edit";
}
-export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
+export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => {
const { currentProject } = useProject();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
@@ -76,80 +115,97 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
});
const createProfile = useCreateCertificateProfile();
+ const updateProfile = useUpdateCertificateProfile();
+
+ const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || [];
- const {
- control,
- handleSubmit,
- reset,
- watch,
- setValue,
- formState: { isSubmitting }
- } = useForm({
- resolver: zodResolver(schema),
- defaultValues: {
- name: "",
- slug: "",
- description: "",
- enrollmentType: "api",
- certificateAuthorityId: "",
- certificateTemplateId: "",
- apiConfig: {
- autoRenew: false,
- autoRenewDays: 30
- }
- }
+ const { control, handleSubmit, reset, watch, setValue } = useForm({
+ resolver: zodResolver(isEdit ? editSchema : createSchema),
+ defaultValues: isEdit
+ ? {
+ slug: profile.slug,
+ description: profile.description || "",
+ enrollmentType: profile.enrollmentType,
+ certificateAuthorityId: profile.caId,
+ certificateTemplateId: profile.certificateTemplateId,
+ estConfig: {
+ disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
+ passphrase: "",
+ caChain: ""
+ },
+ apiConfig: {
+ autoRenew: profile.apiConfig?.autoRenew || false,
+ autoRenewDays: profile.apiConfig?.autoRenewDays || 30
+ }
+ }
+ : {
+ 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;
+ if (!currentProject?.id && !isEdit) 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 (isEdit) {
+ const updateData: any = {
+ profileId: profile.id,
+ name: data.slug,
+ description: data.description
+ };
- if (data.enrollmentType === "est" && data.estConfig) {
- payload.estConfig = data.estConfig;
- } else if (data.enrollmentType === "api" && data.apiConfig) {
- payload.apiConfig = data.apiConfig;
+ if (data.enrollmentType === "est" && data.estConfig) {
+ updateData.estConfig = data.estConfig;
+ } else if (data.enrollmentType === "api" && data.apiConfig) {
+ updateData.apiConfig = data.apiConfig;
+ }
+
+ await updateProfile.mutateAsync(updateData);
+ } else {
+ const createData: any = {
+ projectId: currentProject!.id,
+ slug: data.slug,
+ description: data.description,
+ enrollmentType: data.enrollmentType,
+ caId: data.certificateAuthorityId,
+ certificateTemplateId: data.certificateTemplateId
+ };
+
+ if (data.enrollmentType === "est" && data.estConfig) {
+ createData.estConfig = data.estConfig;
+ } else if (data.enrollmentType === "api" && data.apiConfig) {
+ createData.apiConfig = data.apiConfig;
+ }
+
+ await createProfile.mutateAsync(createData);
}
- await createProfile.mutateAsync(payload);
createNotification({
- text: "Certificate profile created successfully",
+ text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`,
type: "success"
});
reset();
onClose();
} catch (error) {
- console.error("Error creating profile:", error);
+ console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error);
createNotification({
- text: "Failed to create certificate profile",
+ text: `Failed to ${isEdit ? "update" : "create"} certificate profile`,
type: "error"
});
}
@@ -166,25 +222,14 @@ export const CreateProfileModal = ({ isOpen, onClose }: Props) => {
}}
>