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 + }; +};