PKI revamp - backend initial changes

This commit is contained in:
Carlos Monastyrski
2025-10-09 00:29:55 -03:00
parent c1833b5cbd
commit c4dcc3dd83
47 changed files with 9624 additions and 26 deletions

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -0,0 +1,117 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
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);
}

View File

@@ -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<typeof ApiEnrollmentConfigsSchema>;
export type TApiEnrollmentConfigsInsert = Omit<z.input<typeof ApiEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TApiEnrollmentConfigsUpdate = Partial<Omit<z.input<typeof ApiEnrollmentConfigsSchema>, TImmutableDBKeys>>;

View File

@@ -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<typeof CertificateProfilesSchema>;
export type TCertificateProfilesInsert = Omit<z.input<typeof CertificateProfilesSchema>, TImmutableDBKeys>;
export type TCertificateProfilesUpdate = Partial<Omit<z.input<typeof CertificateProfilesSchema>, TImmutableDBKeys>>;

View File

@@ -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<typeof CertificateTemplatesV2Schema>;
export type TCertificateTemplatesV2Insert = Omit<z.input<typeof CertificateTemplatesV2Schema>, TImmutableDBKeys>;
export type TCertificateTemplatesV2Update = Partial<
Omit<z.input<typeof CertificateTemplatesV2Schema>, TImmutableDBKeys>
>;

View File

@@ -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<typeof CertificatesSchema>;

View File

@@ -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<typeof EstEnrollmentConfigsSchema>;
export type TEstEnrollmentConfigsInsert = Omit<z.input<typeof EstEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TEstEnrollmentConfigsUpdate = Partial<Omit<z.input<typeof EstEnrollmentConfigsSchema>, TImmutableDBKeys>>;

View File

@@ -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";

View File

@@ -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",

View File

@@ -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
});
}
});

View File

@@ -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

View File

@@ -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);

View File

@@ -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<ProjectPermissionSub.PamAccounts> & 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([

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 };
}
});
};

View File

@@ -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" });

View File

@@ -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
};
}
});
};

View File

@@ -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" });

View File

@@ -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<typeof item> => 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;
}
});
};

View File

@@ -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" });
};

View File

@@ -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

View File

@@ -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
});

View File

@@ -131,6 +131,8 @@ export type TIssueCertFromCaDTO = {
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO =

View File

@@ -0,0 +1,119 @@
interface CertificateRequestInput {
keyUsages?: string[];
extendedKeyUsages?: string[];
[key: string]: unknown;
}
export const mapEnumsForValidation = <T extends CertificateRequestInput>(request: T): T => {
const keyUsageMapping: Record<string, string> = {
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<string, string> = {
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<string, unknown>,
templateAttributes?: Array<{
type: string;
include: "mandatory" | "optional" | "prohibit";
value?: string[];
}>
): Record<string, string | undefined> => {
const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = {
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(",");
};

View File

@@ -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<TInternalCertificateAuthorityServiceFactory, "signCertFromCa">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "findById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById" | "findByIdWithAssociatedCa">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
estEnrollmentConfigDAL: Pick<TEstEnrollmentConfigDALFactory, "findById">;
};
export type TCertificateEstV3ServiceFactory = ReturnType<typeof certificateEstV3ServiceFactory>;
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<string> = 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<string> = 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
};
};

View File

@@ -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<typeof certificateProfileDALFactory>;
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<TCertificateProfileCertificate[]> => {
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<TCertificateProfileMetrics> => {
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<string, unknown>)?.total_certificates || 0), 10),
activeCertificates: parseInt(String((metrics as Record<string, unknown>)?.active_certificates || 0), 10),
expiredCertificates: parseInt(String((metrics as Record<string, unknown>)?.expired_certificates || 0), 10),
expiringCertificates: parseInt(String((metrics as Record<string, unknown>)?.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String((metrics as Record<string, unknown>)?.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
};
};

View File

@@ -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)
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<TCertificateProfileInsert, "estConfigId" | "apiConfigId"> & {
estConfig?: TEstConfigData;
apiConfig?: TApiConfigData;
};
type TCertificateProfileServiceFactoryDep = {
certificateProfileDAL: TCertificateProfileDALFactory;
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
return {
...dalResult,
enrollmentType: dalResult.enrollmentType as EnrollmentType
} as TCertificateProfile;
};
const convertDalArrayToService = (dalResults: Record<string, unknown>[]): TCertificateProfile[] => {
return dalResults.map(convertDalToService);
};
const validateEnrollmentConfig = async (data: {
enrollmentType: EnrollmentType;
estConfig?: TEstConfigData | null;
apiConfig?: TApiConfigData | null;
}): Promise<void> => {
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<void> => {
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<TCertificateProfileCreateData, "projectId">;
}): Promise<TCertificateProfile> => {
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<TCertificateProfile> => {
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<TCertificateProfile> => {
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<TCertificateProfileWithConfigs> => {
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<TCertificateProfile> => {
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<TCertificateProfile> => {
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<TCertificateProfileCertificate[]> => {
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<TCertificateProfileMetrics> => {
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
};
};

View File

@@ -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<TCertificateProfiles, "enrollmentType"> & {
enrollmentType: EnrollmentType;
};
export type TCertificateProfileInsert = Omit<TCertificateProfilesInsert, "enrollmentType"> & {
enrollmentType: EnrollmentType;
};
export type TCertificateProfileUpdate = Omit<TCertificateProfilesUpdate, "enrollmentType"> & {
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;
}

View File

@@ -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<typeof certificateTemplateV2DALFactory>;
export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const certificateTemplateV2Orm = ormify(db, TableName.CertificateTemplateV2);
const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
const serialized = { ...data } as Record<string, unknown>;
const jsonFields = [
"attributes",
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
jsonFields.forEach((field) => {
const value = (data as Record<string, unknown>)[field];
if (value !== undefined) {
serialized[field] = JSON.stringify(value);
}
});
return serialized;
};
const parseJsonFields = (raw: Record<string, unknown>): 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<string, unknown>) => 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
};
};

View File

@@ -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
});

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateTemplateV2ServiceFactory = ReturnType<typeof certificateTemplateV2ServiceFactory>;
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<TTemplateV2Policy>): 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<TCertificateTemplateV2Insert, "projectId">;
}): Promise<TCertificateTemplateV2> => {
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<TCertificateTemplateV2> => {
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<TCertificateTemplateV2> => {
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<TCertificateTemplateV2> => {
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<TTemplateValidationResult> => {
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
};
};

View File

@@ -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[];
}

View File

@@ -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");
});
});
});

View File

@@ -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<TCertificateDALFactory, "findOne" | "updateById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateTemplateV2Service: Pick<
TCertificateTemplateV2ServiceFactory,
"validateCertificateRequest" | "getTemplateV2ById"
>;
internalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
const validateProfileAndPermissions = async (
profileId: string,
actor: ActorType,
actorId: string,
actorAuthMethod: ActorAuthMethod,
actorOrgId: string,
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">,
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">,
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<TCertificateFromProfileResponse> => {
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<Omit<TCertificateFromProfileResponse, "privateKey">> => {
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<TCertificateOrderResponse> => {
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
};
};

View File

@@ -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<TProjectPermission, "projectId">;
export type TSignCertificateFromProfileDTO = {
profileId: string;
csr: string;
validity: {
ttl: string;
};
notBefore?: Date;
notAfter?: Date;
} & Omit<TProjectPermission, "projectId">;
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<TProjectPermission, "projectId">;
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;
};

View File

@@ -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<typeof apiEnrollmentConfigDALFactory>;
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
};
};

View File

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

View File

@@ -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<typeof estEnrollmentConfigDALFactory>;
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
};
};