mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
PKI revamp - backend initial changes
This commit is contained in:
428
backend/e2e-test/routes/est/certificate-est.spec.ts
Normal file
428
backend/e2e-test/routes/est/certificate-est.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
391
backend/e2e-test/routes/v1/certificate-profiles.spec.ts
Normal file
391
backend/e2e-test/routes/v1/certificate-profiles.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
292
backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
Normal file
292
backend/e2e-test/routes/v2/certificate-templates-v2.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
855
backend/e2e-test/routes/v3/certificates.spec.ts
Normal file
855
backend/e2e-test/routes/v3/certificates.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
8
backend/src/@types/fastify.d.ts
vendored
8
backend/src/@types/fastify.d.ts
vendored
@@ -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;
|
||||
|
||||
32
backend/src/@types/knex.d.ts
vendored
32
backend/src/@types/knex.d.ts
vendored
@@ -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,
|
||||
|
||||
117
backend/src/db/migrations/20251007133321_pki-v3-tables.ts
Normal file
117
backend/src/db/migrations/20251007133321_pki-v3-tables.ts
Normal 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);
|
||||
}
|
||||
20
backend/src/db/schemas/api-enrollment-configs.ts
Normal file
20
backend/src/db/schemas/api-enrollment-configs.ts
Normal 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>>;
|
||||
27
backend/src/db/schemas/certificate-profiles.ts
Normal file
27
backend/src/db/schemas/certificate-profiles.ts
Normal 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>>;
|
||||
30
backend/src/db/schemas/certificate-templates-v2.ts
Normal file
30
backend/src/db/schemas/certificate-templates-v2.ts
Normal 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>
|
||||
>;
|
||||
@@ -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>;
|
||||
|
||||
23
backend/src/db/schemas/est-enrollment-configs.ts
Normal file
23
backend/src/db/schemas/est-enrollment-configs.ts
Normal 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>>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
387
backend/src/server/routes/v1/certificate-profiles-router.ts
Normal file
387
backend/src/server/routes/v1/certificate-profiles-router.ts
Normal 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 };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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" });
|
||||
|
||||
265
backend/src/server/routes/v2/certificate-templates-v2-router.ts
Normal file
265
backend/src/server/routes/v2/certificate-templates-v2-router.ts
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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" });
|
||||
|
||||
324
backend/src/server/routes/v3/certificates-router.ts
Normal file
324
backend/src/server/routes/v3/certificates-router.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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" });
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -131,6 +131,8 @@ export type TIssueCertFromCaDTO = {
|
||||
notAfter?: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TSignCertFromCaDTO =
|
||||
|
||||
119
backend/src/services/certificate-common/certificate-utils.ts
Normal file
119
backend/src/services/certificate-common/certificate-utils.ts
Normal 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(",");
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
395
backend/src/services/certificate-v3/certificate-v3-service.ts
Normal file
395
backend/src/services/certificate-v3/certificate-v3-service.ts
Normal 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
|
||||
};
|
||||
};
|
||||
104
backend/src/services/certificate-v3/certificate-v3-types.ts
Normal file
104
backend/src/services/certificate-v3/certificate-v3-types.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user