Fix router missmatch

This commit is contained in:
Carlos Monastyrski
2025-12-01 12:55:48 -03:00
parent 1145210145
commit 95a26eca9b
3 changed files with 592 additions and 460 deletions
@@ -2,9 +2,10 @@
import RE2 from "re2"; import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { CertificatesSchema } from "@app/db/schemas"; import { CertificatesSchema, TCertificateRequests } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs"; import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs";
import { NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { addNoCacheHeaders } from "@app/server/lib/caching"; import { addNoCacheHeaders } from "@app/server/lib/caching";
@@ -17,6 +18,7 @@ import {
CertSignatureAlgorithm, CertSignatureAlgorithm,
CrlReason CrlReason
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
@@ -26,6 +28,7 @@ import {
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
import { booleanSchema } from "../sanitizedSchemas"; import { booleanSchema } from "../sanitizedSchemas";
@@ -62,7 +65,397 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
return true; return true;
}; };
const validateCertificateRequestFlow = (data: {
csr?: string;
subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>;
commonName?: string;
altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>;
}) => {
const hasCSR = !!data.csr;
const hasSANs = !!data.subjectAlternativeNames?.length;
const hasStandardFields = !!(data.commonName || data.altNames?.length);
const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields);
return flowCount === 1;
};
export const registerCertificateRouter = async (server: FastifyZodProvider) => { export const registerCertificateRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
projectId: z.string().uuid(),
commonName: validateTemplateRegexField.optional(),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
altNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "SAN value cannot be empty")
})
)
.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm),
csr: z
.string()
.trim()
.min(1, "CSR cannot be empty")
.max(4096, "CSR cannot exceed 4096 characters")
.optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z
.string()
.trim()
.min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters")
})
)
.optional(),
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
removeRootsFromChain: booleanSchema.default(false).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
})
.refine(validateCertificateRequestFlow, {
message:
"Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)"
}),
response: {
200: z.union([
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(),
certificateRequestId: z.string()
}),
z.object({
certificateRequestId: z.string(),
status: z.string(),
orderId: z.string().optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
)
.optional(),
authorizations: z
.array(
z.object({
identifier: z.object({
type: z.nativeEnum(ACMESANType),
value: z.string()
}),
status: z.nativeEnum(CertificateOrderStatus),
expires: z.string().optional(),
challenges: z.array(
z.object({
type: z.string(),
status: z.nativeEnum(CertificateOrderStatus),
url: z.string(),
token: z.string()
})
)
})
)
.optional(),
finalize: z.string().optional()
})
])
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { csr, subjectAlternativeNames, ...requestBody } = req.body;
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.projectId,
profileId: requestBody.profileId,
csr,
commonName: requestBody.commonName,
altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
keyAlgorithm: requestBody.keyAlgorithm,
signatureAlgorithm: requestBody.signatureAlgorithm,
metadata: JSON.stringify({
ttl: requestBody.ttl,
removeRootsFromChain: requestBody.removeRootsFromChain,
subjectAlternativeNames
})
});
try {
if (csr) {
const extractedCsrData = extractCertificateRequestFromCSR(csr);
const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
csr,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
enrollmentType: EnrollmentType.API,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
profileName: data.profileName,
commonName: extractedCsrData.commonName || ""
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
}
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId
});
let useOrderFlow = false;
if (profile?.caId) {
const ca = await server.services.certificateAuthority.getCaById({
caId: profile.caId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL;
useOrderFlow = caType !== CaType.INTERNAL;
}
if (subjectAlternativeNames?.length || useOrderFlow) {
let acmeAltNames = subjectAlternativeNames;
if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) {
acmeAltNames = requestBody.altNames.map((alt) => ({
type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME
? ACMESANType.DNS
: ACMESANType.IP) as ACMESANType,
value: alt.value
}));
}
const certificateOrderObject = {
altNames: acmeAltNames || [],
validity: { ttl: requestBody.ttl },
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const data = await server.services.certificateV3.orderCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateOrder: certificateOrderObject,
removeRootsFromChain: requestBody.removeRootsFromChain,
certificateRequestId: certificateRequest.id
});
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.PENDING,
errorMessage: undefined
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
orderId: data.orderId,
profileName: data.profileName
}
}
});
return {
certificateRequestId: certificateRequest.id,
...data
};
}
const certificateRequestForService: CertificateRequestForService = {
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
altNames: requestBody.altNames,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService);
const data = await server.services.certificateV3.issueCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateRequest: mappedCertificateRequest,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
commonName: requestBody.commonName || "",
profileName: data.profileName
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
} catch (error) {
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.FAILED,
errorMessage: error instanceof Error ? error.message : "Unknown error"
});
throw error;
}
}
});
server.route({
method: "GET",
url: "/certificate-requests/:requestId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
params: z.object({
requestId: z.string().uuid()
}),
query: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
status: z.nativeEnum(CertificateRequestStatus),
certificate: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable(),
errorMessage: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.certificateRequest.getCertificateFromRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: (req.query as { projectId: string }).projectId,
certificateRequestId: req.params.requestId
});
if (data.certificate && data.serialNumber) {
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: (req.query as { projectId: string }).projectId,
event: {
type: EventType.GET_CERT,
metadata: {
certId: req.params.requestId,
cn: "",
serialNumber: data.serialNumber || ""
}
}
});
}
return data;
}
});
server.route({ server.route({
method: "POST", method: "POST",
url: "/issue-certificate", url: "/issue-certificate",
@@ -70,8 +463,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -111,7 +506,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
@@ -143,6 +539,28 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
commonName: req.body.commonName,
altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","),
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,
keyAlgorithm: req.body.keyAlgorithm,
signatureAlgorithm: req.body.signatureAlgorithm
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -157,7 +575,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -168,8 +589,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -196,14 +619,13 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
issuingCaCertificate: z.string().trim(), issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({ const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -220,6 +642,31 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
csr: req.body.csr,
commonName: certificateRequestData.commonName,
altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
keyUsages: certificateRequestData.keyUsages,
extendedKeyUsages: certificateRequestData.extendedKeyUsages,
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
keyAlgorithm: certificateRequestData.keyAlgorithm,
signatureAlgorithm: certificateRequestData.signatureAlgorithm
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -229,12 +676,15 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
profileName: data.profileName, profileName: data.profileName,
commonName: certificateRequest.commonName || "" commonName: certificateRequestData.commonName || ""
} }
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -245,23 +695,23 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
subjectAlternativeNames: z subjectAlternativeNames: z.array(
.array( z.object({
z.object({ type: z.nativeEnum(ACMESANType),
type: z.nativeEnum(ACMESANType), value: z
value: z .string()
.string() .trim()
.trim() .min(1, "SAN value cannot be empty")
.min(1, "SAN value cannot be empty") .max(255, "SAN value must be less than 255 characters")
.max(255, "SAN value must be less than 255 characters") })
}) ),
)
.min(1, "At least one subject alternative name must be provided"),
ttl: z ttl: z
.string() .string()
.trim() .trim()
@@ -313,34 +763,54 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
}) })
), ),
finalize: z.string(), finalize: z.string(),
certificate: z.string().optional() certificate: z.string().optional(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateOrderObject = {
altNames: req.body.subjectAlternativeNames,
validity: {
ttl: req.body.ttl
},
commonName: req.body.commonName,
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 data = await server.services.certificateV3.orderCertificateFromProfile({ const data = await server.services.certificateV3.orderCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
profileId: req.body.profileId, profileId: req.body.profileId,
certificateOrder: { certificateOrder: certificateOrderObject,
altNames: req.body.subjectAlternativeNames,
validity: {
ttl: req.body.ttl
},
commonName: req.body.commonName,
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
},
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
commonName: req.body.commonName,
altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
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
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -354,7 +824,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -382,36 +855,87 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const data = await server.services.certificateV3.renewCertificate({ let certificateRequest: TCertificateRequests | undefined;
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
certificateId: req.params.id,
removeRootsFromChain: req.body?.removeRootsFromChain
});
await server.services.auditLog.createAuditLog({ try {
...req.auditLogInfo, const originalCertificate = await server.services.certificate.getCert({
projectId: data.projectId, actor: req.permission.type,
event: { actorId: req.permission.id,
type: EventType.RENEW_CERTIFICATE, actorAuthMethod: req.permission.authMethod,
metadata: { actorOrgId: req.permission.orgId,
originalCertificateId: req.params.id, id: req.params.id
newCertificateId: data.certificateId, });
profileName: data.profileName, if (!originalCertificate) {
commonName: data.commonName throw new NotFoundError({ message: "Original certificate not found" });
}
} }
});
return data; certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: originalCertificate.cert.projectId,
profileId: originalCertificate.cert.profileId || undefined,
caId: originalCertificate.cert.caId ?? undefined,
metadata: JSON.stringify({
operation: "renewal",
originalCertificateId: req.params.id,
removeRootsFromChain: req.body?.removeRootsFromChain
})
});
const data = await server.services.certificateV3.renewCertificate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
certificateId: req.params.id,
removeRootsFromChain: req.body?.removeRootsFromChain,
certificateRequestId: certificateRequest.id
});
if (data.certificate && data.certificate.trim() !== "") {
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.RENEW_CERTIFICATE,
metadata: {
originalCertificateId: req.params.id,
newCertificateId: data.certificateId,
profileName: data.profileName,
commonName: data.commonName
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
} catch (error) {
if (certificateRequest) {
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.FAILED,
errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal"
});
}
throw error;
}
} }
}); });
@@ -5,7 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs"; import { ApiDocsTags } from "@app/lib/api-docs";
import { NotFoundError } from "@app/lib/errors"; import { NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { import {
@@ -14,7 +14,6 @@ import {
CertKeyAlgorithm, CertKeyAlgorithm,
CertSignatureAlgorithm CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
@@ -61,398 +60,7 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
return true; return true;
}; };
const validateCertificateRequestFlow = (data: {
csr?: string;
subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>;
commonName?: string;
altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>;
}) => {
const hasCSR = !!data.csr;
const hasSANs = !!data.subjectAlternativeNames?.length;
const hasStandardFields = !!(data.commonName || data.altNames?.length);
const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields);
return flowCount === 1;
};
export const registerCertificatesRouter = async (server: FastifyZodProvider) => { export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
projectId: z.string().uuid(),
commonName: validateTemplateRegexField.optional(),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
altNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "SAN value cannot be empty")
})
)
.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm),
csr: z
.string()
.trim()
.min(1, "CSR cannot be empty")
.max(4096, "CSR cannot exceed 4096 characters")
.optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z
.string()
.trim()
.min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters")
})
)
.optional(),
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
removeRootsFromChain: booleanSchema.default(false).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
})
.refine(validateCertificateRequestFlow, {
message:
"Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)"
}),
response: {
200: z.union([
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(),
certificateRequestId: z.string()
}),
z.object({
certificateRequestId: z.string(),
status: z.string(),
orderId: z.string().optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
)
.optional(),
authorizations: z
.array(
z.object({
identifier: z.object({
type: z.nativeEnum(ACMESANType),
value: z.string()
}),
status: z.nativeEnum(CertificateOrderStatus),
expires: z.string().optional(),
challenges: z.array(
z.object({
type: z.string(),
status: z.nativeEnum(CertificateOrderStatus),
url: z.string(),
token: z.string()
})
)
})
)
.optional(),
finalize: z.string().optional()
})
])
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { csr, subjectAlternativeNames, ...requestBody } = req.body;
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.projectId,
profileId: requestBody.profileId,
csr,
commonName: requestBody.commonName,
altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
keyAlgorithm: requestBody.keyAlgorithm,
signatureAlgorithm: requestBody.signatureAlgorithm,
metadata: JSON.stringify({
ttl: requestBody.ttl,
removeRootsFromChain: requestBody.removeRootsFromChain,
subjectAlternativeNames
})
});
try {
if (csr) {
const extractedCsrData = extractCertificateRequestFromCSR(csr);
const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
csr,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
enrollmentType: EnrollmentType.API,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
profileName: data.profileName,
commonName: extractedCsrData.commonName || ""
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
}
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId
});
let useOrderFlow = false;
if (profile?.caId) {
const ca = await server.services.certificateAuthority.getCaById({
caId: profile.caId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL;
useOrderFlow = caType !== CaType.INTERNAL;
}
if (subjectAlternativeNames?.length || useOrderFlow) {
let acmeAltNames = subjectAlternativeNames;
if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) {
acmeAltNames = requestBody.altNames.map((alt) => ({
type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME
? ACMESANType.DNS
: ACMESANType.IP) as ACMESANType,
value: alt.value
}));
}
const certificateOrderObject = {
altNames: acmeAltNames || [],
validity: { ttl: requestBody.ttl },
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const data = await server.services.certificateV3.orderCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateOrder: certificateOrderObject,
removeRootsFromChain: requestBody.removeRootsFromChain,
certificateRequestId: certificateRequest.id
});
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.PENDING,
errorMessage: undefined
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
orderId: data.orderId,
profileName: data.profileName
}
}
});
return {
certificateRequestId: certificateRequest.id,
...data
};
}
const certificateRequestForService: CertificateRequestForService = {
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
altNames: requestBody.altNames,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService);
const data = await server.services.certificateV3.issueCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateRequest: mappedCertificateRequest,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
commonName: requestBody.commonName || "",
profileName: data.profileName
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
} catch (error) {
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.FAILED,
errorMessage: error instanceof Error ? error.message : "Unknown error"
});
throw error;
}
}
});
server.route({
method: "GET",
url: "/certificate-requests/:certificateRequestId/certificate",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
params: z.object({
certificateRequestId: z.string().uuid()
}),
query: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
status: z.nativeEnum(CertificateRequestStatus),
certificate: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable(),
errorMessage: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.certificateRequest.getCertificateFromRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: (req.query as { projectId: string }).projectId,
certificateRequestId: req.params.certificateRequestId
});
if (data.certificate && data.serialNumber) {
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: (req.query as { projectId: string }).projectId,
event: {
type: EventType.GET_CERT,
metadata: {
certId: req.params.certificateRequestId,
cn: "",
serialNumber: data.serialNumber || ""
}
}
});
}
return data;
}
});
server.route({ server.route({
method: "POST", method: "POST",
url: "/issue-certificate", url: "/issue-certificate",
@@ -463,7 +71,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
hide: true, hide: true,
deprecated: true, deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -586,10 +194,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: true, // Hide deprecated endpoint from docs hide: true,
deprecated: true, deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -692,10 +300,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: true, // Hide deprecated endpoint from docs hide: true,
deprecated: true, deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.", description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -867,7 +475,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
serialNumber: req.params.certificateId id: req.params.certificateId
}); });
if (!originalCertificate) { if (!originalCertificate) {
throw new NotFoundError({ message: "Original certificate not found" }); throw new NotFoundError({ message: "Original certificate not found" });
@@ -194,7 +194,7 @@ export const useUnifiedCertificateIssuance = () => {
mutationFn: async (body) => { mutationFn: async (body) => {
const { projectSlug, ...requestData } = body; const { projectSlug, ...requestData } = body;
const { data } = await apiRequest.post<TUnifiedCertificateIssuanceResponse>( const { data } = await apiRequest.post<TUnifiedCertificateIssuanceResponse>(
"/api/v3/pki/certificates", "/api/v1/cert-manager/certificates",
requestData, requestData,
{ {
params: { projectSlug } params: { projectSlug }