Add external CA support, certificate requests and profile external configurations

This commit is contained in:
Carlos Monastyrski
2025-11-30 20:42:34 -03:00
parent e719de8710
commit dbd4a85fc8
42 changed files with 3949 additions and 316 deletions

View File

@@ -57,3 +57,4 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246

View File

@@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a
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 { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-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";
@@ -288,6 +289,7 @@ declare module "fastify" {
auditLogStream: TAuditLogStreamServiceFactory;
certificate: TCertificateServiceFactory;
certificateV3: TCertificateV3ServiceFactory;
certificateRequest: TCertificateRequestServiceFactory;
certificateTemplate: TCertificateTemplateServiceFactory;
certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
certificateProfile: TCertificateProfileServiceFactory;

View File

@@ -573,6 +573,11 @@ import {
TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate
} from "@app/db/schemas";
import {
TCertificateRequests,
TCertificateRequestsInsert,
TCertificateRequestsUpdate
} from "@app/db/schemas/certificate-requests";
import {
TAccessApprovalPoliciesEnvironments,
TAccessApprovalPoliciesEnvironmentsInsert,
@@ -714,6 +719,11 @@ declare module "knex/types/tables" {
TExternalCertificateAuthoritiesUpdate
>;
[TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
[TableName.CertificateRequests]: KnexOriginal.CompositeTableType<
TCertificateRequests,
TCertificateRequestsInsert,
TCertificateRequestsUpdate
>;
[TableName.CertificateTemplate]: KnexOriginal.CompositeTableType<
TCertificateTemplates,
TCertificateTemplatesInsert,

View File

@@ -0,0 +1,47 @@
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.CertificateRequests))) {
await knex.schema.createTable(TableName.CertificateRequests, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.timestamps(true, true, true);
t.string("status").notNullable();
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("profileId").nullable();
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL");
t.uuid("caId").nullable();
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL");
t.uuid("certificateId").nullable();
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
t.text("csr").nullable();
t.string("commonName").nullable();
t.text("altNames").nullable();
t.specificType("keyUsages", "text[]").nullable();
t.specificType("extendedKeyUsages", "text[]").nullable();
t.datetime("notBefore").nullable();
t.datetime("notAfter").nullable();
t.string("keyAlgorithm").nullable();
t.string("signatureAlgorithm").nullable();
t.text("errorMessage").nullable();
t.text("metadata").nullable();
t.index(["projectId"]);
t.index(["status"]);
t.index(["profileId"]);
t.index(["caId"]);
t.index(["certificateId"]);
t.index(["createdAt"]);
});
}
await createOnUpdateTrigger(knex, TableName.CertificateRequests);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.CertificateRequests);
await dropOnUpdateTrigger(knex, TableName.CertificateRequests);
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
if (!hasExternalConfigs) {
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
t.text("externalConfigs").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
if (hasExternalConfigs) {
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
t.dropColumn("externalConfigs");
});
}
}

View File

@@ -0,0 +1,34 @@
// 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 CertificateRequestsSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
status: z.string(),
projectId: z.string(),
profileId: z.string().uuid().nullable().optional(),
caId: z.string().uuid().nullable().optional(),
certificateId: z.string().uuid().nullable().optional(),
csr: z.string().nullable().optional(),
commonName: z.string().nullable().optional(),
altNames: z.string().nullable().optional(),
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional(),
notBefore: z.date().nullable().optional(),
notAfter: z.date().nullable().optional(),
keyAlgorithm: z.string().nullable().optional(),
signatureAlgorithm: z.string().nullable().optional(),
errorMessage: z.string().nullable().optional(),
metadata: z.string().nullable().optional()
});
export type TCertificateRequests = z.infer<typeof CertificateRequestsSchema>;
export type TCertificateRequestsInsert = Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>;
export type TCertificateRequestsUpdate = Partial<Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>>;

View File

@@ -16,6 +16,7 @@ export * from "./certificate-authority-certs";
export * from "./certificate-authority-crl";
export * from "./certificate-authority-secret";
export * from "./certificate-bodies";
export * from "./certificate-requests";
export * from "./certificate-secrets";
export * from "./certificate-syncs";
export * from "./certificate-template-est-configs";

View File

@@ -21,6 +21,7 @@ export enum TableName {
CertificateAuthorityCrl = "certificate_authority_crl",
Certificate = "certificates",
CertificateBody = "certificate_bodies",
CertificateRequests = "certificate_requests",
CertificateSecret = "certificate_secrets",
CertificateTemplate = "certificate_templates",
PkiCertificateTemplateV2 = "pki_certificate_templates_v2",

View File

@@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
acmeConfigId: z.string().uuid().nullable().optional(),
issuerType: z.string().default("ca")
issuerType: z.string().default("ca"),
externalConfigs: z.string().nullable().optional()
});
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;

View File

@@ -94,7 +94,7 @@ import {
type TPkiAcmeServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
@@ -776,6 +776,7 @@ export const pkiAcmeServiceFactory = ({
const cert = await orderCertificate(
{
caId: certificateAuthority!.id,
profileId,
commonName: certificateRequest.commonName!,
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
csr: Buffer.from(csrPem),

View File

@@ -63,6 +63,7 @@ export enum QueueName {
DynamicSecretRevocation = "dynamic-secret-revocation",
CaCrlRotation = "ca-crl-rotation",
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
CertificateIssuance = "certificate-issuance",
SecretReplication = "secret-replication",
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
PkiSync = "pki-sync",
@@ -125,6 +126,7 @@ export enum QueueJobs {
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile",
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders",
@@ -343,6 +345,21 @@ export type TQueueJobTypes = {
caType: CaType;
};
};
[QueueName.CertificateIssuance]: {
name: QueueJobs.CaIssueCertificateFromProfile;
payload: {
certificateId: string;
profileId: string;
caId: string;
commonName?: string;
altNames?: string[];
ttl: string;
signatureAlgorithm: string;
keyAlgorithm: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
};
};
[QueueName.DailyReminders]: {
name: QueueJobs.DailyReminders;
payload: undefined;

View File

@@ -173,6 +173,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author
import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue";
import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
import { certificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue";
import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
@@ -180,6 +181,8 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif
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 { certificateRequestDALFactory } from "@app/services/certificate-request/certificate-request-dal";
import { certificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
@@ -1092,6 +1095,7 @@ export const registerRoutes = async (
const certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db);
const certificateSecretDAL = certificateSecretDALFactory(db);
const certificateRequestDAL = certificateRequestDALFactory(db);
const certificateSyncDAL = certificateSyncDALFactory(db);
const pkiAlertDAL = pkiAlertDALFactory(db);
@@ -1187,7 +1191,7 @@ export const registerRoutes = async (
certificateBodyDAL,
certificateSecretDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
externalCertificateAuthorityDAL,
permissionService,
licenseService,
kmsService,
@@ -2208,6 +2212,31 @@ export const registerRoutes = async (
pkiSyncQueue
});
const certificateRequestService = certificateRequestServiceFactory({
certificateRequestDAL,
certificateDAL,
certificateService,
permissionService
});
const certificateIssuanceQueue = certificateIssuanceQueueFactory({
certificateAuthorityDAL,
appConnectionDAL,
appConnectionService,
externalCertificateAuthorityDAL,
certificateDAL,
projectDAL,
kmsService,
certificateBodyDAL,
certificateSecretDAL,
queueService,
pkiSubscriberDAL,
pkiSyncDAL,
pkiSyncQueue,
certificateProfileDAL,
certificateRequestService
});
const certificateV3Service = certificateV3ServiceFactory({
certificateDAL,
certificateSecretDAL,
@@ -2222,7 +2251,8 @@ export const registerRoutes = async (
pkiSyncQueue,
kmsService,
projectDAL,
certificateBodyDAL
certificateBodyDAL,
certificateIssuanceQueue
});
const certificateV3Queue = certificateV3QueueServiceFactory({
@@ -2448,6 +2478,7 @@ export const registerRoutes = async (
await pkiSubscriberQueue.startDailyAutoRenewalJob();
await pkiAlertV2Queue.init();
await certificateV3Queue.init();
await certificateIssuanceQueue.initializeCertificateIssuanceQueue();
await microsoftTeamsService.start();
await dynamicSecretQueueService.init();
await eventBusService.init();
@@ -2513,6 +2544,7 @@ export const registerRoutes = async (
auditLogStream: auditLogStreamService,
certificate: certificateService,
certificateV3: certificateV3Service,
certificateRequest: certificateRequestService,
certificateEstV3: certificateEstV3Service,
sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService,

View File

@@ -46,7 +46,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z.object({}).optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
})
.refine(
(data) => {
@@ -149,7 +150,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
})
})
}
},
@@ -204,6 +207,15 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
response: {
200: z.object({
certificateProfiles: PkiCertificateProfilesSchema.extend({
certificateAuthority: z
.object({
id: z.string(),
status: z.string(),
name: z.string(),
isExternal: z.boolean().optional(),
externalType: z.string().nullable().optional()
})
.optional(),
metrics: z
.object({
profileId: z.string(),
@@ -234,7 +246,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
id: z.string(),
directoryUrl: z.string()
})
.optional()
.optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
}).array(),
totalCount: z.number()
})
@@ -280,12 +293,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
}).extend({
certificateAuthority: z
.object({
id: z.string(),
projectId: z.string(),
projectId: z.string().optional(),
status: z.string(),
name: z.string()
name: z.string(),
isExternal: z.boolean().optional(),
externalType: z.string().nullable().optional()
})
.optional(),
certificateTemplate: z
@@ -310,7 +327,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(),
renewBeforeDays: z.number().optional()
})
.optional()
.optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
})
})
}
@@ -358,7 +376,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
})
})
}
},
@@ -412,7 +432,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional()
.optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
})
.refine(
(data) => {
@@ -434,7 +455,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
})
})
}
},
@@ -479,7 +502,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
})
})
}
},

View File

@@ -1,9 +1,11 @@
import { z } from "zod";
import { TCertificateRequests } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
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 {
@@ -12,6 +14,7 @@ import {
CertKeyAlgorithm,
CertSignatureAlgorithm
} 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 {
CertExtendedKeyUsageType,
@@ -21,6 +24,7 @@ import {
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
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 { booleanSchema } from "../sanitizedSchemas";
@@ -57,7 +61,398 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
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) => {
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({
method: "POST",
url: "/issue-certificate",
@@ -65,8 +460,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit
},
schema: {
hide: false,
hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z
.object({
profileId: z.string().uuid(),
@@ -106,7 +503,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(),
certificateId: z.string()
certificateId: z.string(),
certificateRequestId: z.string()
})
}
},
@@ -138,6 +536,28 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
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({
...req.auditLogInfo,
projectId: data.projectId,
@@ -152,7 +572,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}
});
return data;
return {
...data,
certificateRequestId: certificateRequest.id
};
}
});
@@ -163,8 +586,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit
},
schema: {
hide: false,
hide: true, // Hide deprecated endpoint from docs
deprecated: true,
tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z
.object({
profileId: z.string().uuid(),
@@ -191,14 +616,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
serialNumber: z.string().trim(),
certificateId: z.string()
certificateId: z.string(),
certificateRequestId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
@@ -215,6 +639,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
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({
...req.auditLogInfo,
projectId: data.projectId,
@@ -224,12 +673,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateProfileId: req.body.profileId,
certificateId: data.certificateId,
profileName: data.profileName,
commonName: certificateRequest.commonName || ""
commonName: certificateRequestData.commonName || ""
}
}
});
return data;
return {
...data,
certificateRequestId: certificateRequest.id
};
}
});
@@ -240,23 +692,23 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit
},
schema: {
hide: false,
hide: true, // Hide deprecated endpoint from docs
deprecated: true,
tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z
.object({
profileId: z.string().uuid(),
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")
})
)
.min(1, "At least one subject alternative name must be provided"),
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")
})
),
ttl: z
.string()
.trim()
@@ -308,34 +760,54 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
})
),
finalize: z.string(),
certificate: z.string().optional()
certificate: z.string().optional(),
certificateRequestId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
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({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId,
certificateOrder: {
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
},
certificateOrder: certificateOrderObject,
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({
...req.auditLogInfo,
projectId: data.projectId,
@@ -349,7 +821,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}
});
return data;
return {
...data,
certificateRequestId: certificateRequest.id
};
}
});
@@ -377,36 +852,87 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(),
certificateId: z.string()
certificateId: z.string(),
certificateRequestId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
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.certificateId,
removeRootsFromChain: req.body?.removeRootsFromChain
});
let certificateRequest: TCertificateRequests | undefined;
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.RENEW_CERTIFICATE,
metadata: {
originalCertificateId: req.params.certificateId,
newCertificateId: data.certificateId,
profileName: data.profileName,
commonName: data.commonName
}
try {
const originalCertificate = await server.services.certificate.getCert({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
serialNumber: req.params.certificateId
});
if (!originalCertificate) {
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.certificateId,
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.certificateId,
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.certificateId,
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;
}
}
});

View File

@@ -1,6 +1,7 @@
import * as x509 from "@peculiar/x509";
import acme, { CsrBuffer } from "acme-client";
import { Knex } from "knex";
import RE2 from "re2";
import { TableName } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
@@ -23,6 +24,7 @@ import {
CertKeyUsage,
CertStatus
} from "@app/services/certificate/certificate-types";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
@@ -45,6 +47,60 @@ import {
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
const parseTtlToDays = (ttl: string): number => {
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
if (!match) {
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
}
const [, value, unit] = match;
const num = parseInt(value, 10);
switch (unit) {
case "d":
return num;
case "h":
return Math.ceil(num / 24);
case "m":
return Math.ceil(num / (24 * 60));
default:
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
}
};
const calculateRenewalThreshold = (
profileRenewBeforeDays: number | undefined,
certificateTtlInDays: number
): number | undefined => {
if (profileRenewBeforeDays === undefined) {
return undefined;
}
if (profileRenewBeforeDays >= certificateTtlInDays) {
return Math.max(1, certificateTtlInDays - 1);
}
return profileRenewBeforeDays;
};
const calculateFinalRenewBeforeDays = (
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
ttl: string
): number | undefined => {
if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) {
return undefined;
}
const certificateTtlInDays = parseTtlToDays(ttl);
const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays);
if (!renewBeforeDays) {
return undefined;
}
return renewBeforeDays;
};
type TAcmeCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
@@ -53,7 +109,7 @@ type TAcmeCertificateAuthorityFnsDeps = {
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick<
@@ -64,13 +120,14 @@ type TAcmeCertificateAuthorityFnsDeps = {
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
};
type TOrderCertificateDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick<
@@ -78,6 +135,7 @@ type TOrderCertificateDeps = {
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
>;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
};
type DBConfigurationColumn = {
@@ -91,7 +149,7 @@ type DBConfigurationColumn = {
export const castDbEntryToAcmeCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAcmeCertificateAuthority & { credentials: unknown } => {
): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => {
if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
}
@@ -123,15 +181,22 @@ export const castDbEntryToAcmeCertificateAuthority = (
export const orderCertificate = async (
{
caId,
profileId,
subscriberId,
commonName,
altNames,
csr,
csrPrivateKey,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
ttl,
signatureAlgorithm,
keyAlgorithm,
isRenewal,
originalCertificateId
}: {
caId: string;
profileId?: string;
subscriberId?: string;
commonName: string;
altNames?: string[];
@@ -139,6 +204,11 @@ export const orderCertificate = async (
csrPrivateKey?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
ttl?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
isRenewal?: boolean;
originalCertificateId?: string;
},
deps: TOrderCertificateDeps,
tx?: Knex
@@ -151,7 +221,8 @@ export const orderCertificate = async (
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL
projectDAL,
certificateProfileDAL
} = deps;
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
@@ -181,7 +252,7 @@ export const orderCertificate = async (
let accountKey: Buffer | undefined;
if (acmeCa.credentials) {
const decryptedCredentials = await kmsDecryptor({
cipherTextBlob: acmeCa.credentials as Buffer
cipherTextBlob: acmeCa.credentials
});
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
@@ -322,6 +393,7 @@ export const orderCertificate = async (
{
caId: ca.id,
pkiSubscriberId: subscriberId,
profileId,
status: CertStatus.ACTIVE,
friendlyName: commonName,
commonName,
@@ -331,11 +403,18 @@ export const orderCertificate = async (
notAfter: certObj.notAfter,
keyUsages,
extendedKeyUsages,
projectId: ca.projectId
keyAlgorithm,
signatureAlgorithm,
projectId: ca.projectId,
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
},
innerTx
);
if (isRenewal && originalCertificateId) {
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx);
}
await certificateBodyDAL.create(
{
certId: cert.id,
@@ -355,6 +434,26 @@ export const orderCertificate = async (
);
}
if (profileId && ttl && certificateProfileDAL) {
const profile = await certificateProfileDAL.findById(profileId, innerTx);
if (profile) {
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
profile as { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } },
ttl
);
if (finalRenewBeforeDays !== undefined) {
await certificateDAL.updateById(
cert.id,
{
renewBeforeDays: finalRenewBeforeDays
},
innerTx
);
}
}
}
return cert;
});
};
@@ -371,7 +470,8 @@ export const AcmeCertificateAuthorityFns = ({
projectDAL,
pkiSubscriberDAL,
pkiSyncDAL,
pkiSyncQueue
pkiSyncQueue,
certificateProfileDAL
}: TAcmeCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({
name,
@@ -616,10 +716,71 @@ export const AcmeCertificateAuthorityFns = ({
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
};
const orderCertificateFromProfile = async ({
caId,
profileId,
commonName,
altNames = [],
csr,
csrPrivateKey,
keyUsages,
extendedKeyUsages,
ttl,
signatureAlgorithm,
keyAlgorithm,
isRenewal,
originalCertificateId
}: {
caId: string;
profileId?: string;
commonName: string;
altNames?: string[];
csr: CsrBuffer;
csrPrivateKey: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
ttl?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
isRenewal?: boolean;
originalCertificateId?: string;
}) => {
return orderCertificate(
{
caId,
profileId,
subscriberId: undefined,
commonName,
altNames,
csr,
csrPrivateKey,
keyUsages,
extendedKeyUsages,
ttl,
signatureAlgorithm,
keyAlgorithm,
isRenewal,
originalCertificateId
},
{
appConnectionDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL,
certificateProfileDAL
}
);
};
return {
createCertificateAuthority,
updateCertificateAuthority,
listCertificateAuthorities,
orderSubscriberCertificate
orderSubscriberCertificate,
orderCertificateFromProfile
};
};

View File

@@ -21,8 +21,10 @@ import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus
CertStatus,
TAltNameType
} from "@app/services/certificate/certificate-types";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
@@ -42,6 +44,60 @@ import {
TUpdateAzureAdCsCertificateAuthorityDTO
} from "./azure-ad-cs-certificate-authority-types";
const parseTtlToDays = (ttl: string): number => {
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
if (!match) {
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
}
const [, value, unit] = match;
const num = parseInt(value, 10);
switch (unit) {
case "d":
return num;
case "h":
return Math.ceil(num / 24);
case "m":
return Math.ceil(num / (24 * 60));
default:
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
}
};
const calculateRenewalThreshold = (
profileRenewBeforeDays: number | undefined,
certificateTtlInDays: number
): number | undefined => {
if (profileRenewBeforeDays === undefined) {
return undefined;
}
if (profileRenewBeforeDays >= certificateTtlInDays) {
return Math.max(1, certificateTtlInDays - 1);
}
return profileRenewBeforeDays;
};
const calculateFinalRenewBeforeDays = (
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
ttl: string
): number | undefined => {
const hasAutoRenewEnabled = profile?.apiConfig?.autoRenew === true;
if (!hasAutoRenewEnabled) {
return undefined;
}
const profileRenewBeforeDays = profile?.apiConfig?.renewBeforeDays;
if (profileRenewBeforeDays !== undefined) {
const certificateTtlInDays = parseTtlToDays(ttl);
return calculateRenewalThreshold(profileRenewBeforeDays, certificateTtlInDays);
}
return undefined;
};
type TAzureAdCsCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
@@ -50,7 +106,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick<
@@ -61,6 +117,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
};
type AzureCertificateRequest = {
@@ -190,7 +247,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie
export const castDbEntryToAzureAdCsCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAzureAdCsCertificateAuthority & { credentials: unknown } => {
): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => {
if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
}
@@ -591,7 +648,8 @@ export const AzureAdCsCertificateAuthorityFns = ({
projectDAL,
pkiSubscriberDAL,
pkiSyncDAL,
pkiSyncQueue
pkiSyncQueue,
certificateProfileDAL
}: TAzureAdCsCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({
name,
@@ -1043,6 +1101,384 @@ export const AzureAdCsCertificateAuthorityFns = ({
};
};
const orderCertificateFromProfile = async ({
caId,
profileId,
commonName,
altNames = [],
keyUsages = [],
extendedKeyUsages = [],
template,
validity,
notBefore,
notAfter,
signatureAlgorithm,
keyAlgorithm = CertKeyAlgorithm.RSA_2048,
isRenewal,
originalCertificateId
}: {
caId: string;
profileId: string;
commonName: string;
altNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
template?: string;
validity: { ttl: string };
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: CertKeyAlgorithm;
isRenewal?: boolean;
originalCertificateId?: string;
}) => {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) {
throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" });
}
const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca);
if (azureCa.status !== CaStatus.ACTIVE) {
throw new BadRequestError({ message: "CA is disabled" });
}
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } =
await getAzureADCSConnectionCredentials(
azureCa.configuration.azureAdcsConnectionId,
appConnectionDAL,
kmsService
);
const credentials: {
username: string;
password: string;
sslRejectUnauthorized?: boolean;
sslCertificate?: string;
} = {
username,
password,
sslRejectUnauthorized,
sslCertificate
};
let alg;
if (signatureAlgorithm) {
switch (signatureAlgorithm.toUpperCase()) {
case "RSA-SHA256":
case "SHA256WITHRSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
break;
case "RSA-SHA384":
case "SHA384WITHRSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_3072);
break;
case "RSA-SHA512":
case "SHA512WITHRSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_4096);
break;
case "ECDSA-SHA256":
case "SHA256WITHECDSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P256);
break;
case "ECDSA-SHA384":
case "SHA384WITHECDSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P384);
break;
case "ECDSA-SHA512":
case "SHA512WITHECDSA":
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P521);
break;
default:
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
break;
}
} else {
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
}
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const subjectDN = buildSubjectDN(commonName);
let sanExtension = "";
if (altNames && altNames.length > 0) {
sanExtension = altNames.join(",");
}
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: subjectDN,
keys: leafKeys,
signingAlgorithm: alg,
...(sanExtension && {
extensions: [
new x509.SubjectAlternativeNameExtension(
altNames.map((name) => ({ type: "dns" as TAltNameType, value: name })),
false
)
]
})
});
const csrPem = csrObj.toString("pem");
let templateValue = template;
if (!templateValue) {
templateValue = "WebServer";
}
const templateInput = templateValue.trim();
if (!templateInput || templateInput.length === 0) {
throw new BadRequestError({
message: "Certificate template name cannot be empty"
});
}
let validityPeriod: string | undefined;
if (notBefore && notAfter) {
if (notAfter <= notBefore) {
throw new BadRequestError({
message: "Certificate notAfter date must be after notBefore date"
});
}
const diffMs = notAfter.getTime() - notBefore.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
validityPeriod = `${diffDays}d`;
} else if (notAfter) {
const diffMs = notAfter.getTime() - Date.now();
if (diffMs <= 0) {
throw new BadRequestError({
message: "Certificate notAfter date must be in the future"
});
}
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
validityPeriod = `${diffDays}d`;
} else if (validity.ttl) {
validityPeriod = validity.ttl;
}
const certificateRequest: AzureCertificateRequest = {
csr: csrPem,
template: templateInput,
attributes: {
subject: subjectDN,
...(sanExtension && { subjectAlternativeName: sanExtension }),
...(validityPeriod && { validityPeriod })
}
};
let submissionResponse;
const maxOidRetries = 3;
let oidRetryCount = 0;
while (oidRetryCount <= maxOidRetries) {
try {
submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest);
break;
} catch (error) {
const isOidError =
error instanceof BadRequestError &&
(error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type"));
if (isOidError && oidRetryCount < maxOidRetries) {
oidRetryCount += 1;
const delay = 3000 * oidRetryCount;
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
// eslint-disable-next-line no-continue
continue;
}
throw error;
}
}
if (!submissionResponse) {
throw new BadRequestError({
message: "Failed to submit certificate request after multiple attempts due to OID resolution issues"
});
}
if (submissionResponse.status === "denied") {
throw new BadRequestError({ message: "Certificate request was denied by ADCS" });
}
let certificatePem = "";
if (submissionResponse.status === "issued" && submissionResponse.certificate) {
certificatePem = submissionResponse.certificate;
} else {
const maxRetries = 5;
const initialDelay = 2000;
let retryCount = 0;
let lastError: Error | null = null;
// eslint-disable-next-line no-await-in-loop
while (retryCount < maxRetries) {
try {
// eslint-disable-next-line no-await-in-loop
certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId);
break;
} catch (error) {
lastError = error as Error;
// eslint-disable-next-line no-plusplus
retryCount++;
if (retryCount < maxRetries) {
// Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s
const delay = initialDelay * 2 ** (retryCount - 1);
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
}
}
}
if (retryCount === maxRetries) {
throw new BadRequestError({
message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.`
});
}
}
if (!certificatePem) {
throw new BadRequestError({
message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing."
});
}
let cleanedCertificatePem = certificatePem.trim();
if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) {
throw new BadRequestError({
message: "Invalid certificate format received from ADCS. Expected PEM format."
});
}
cleanedCertificatePem = cleanedCertificatePem
.replace(new RE2("\\r\\n", "g"), "\n")
.replace(new RE2("\\r", "g"), "\n")
.trim();
if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) {
throw new BadRequestError({
message: "Invalid certificate format received from ADCS. Missing end marker."
});
}
let certObj: x509.X509Certificate;
try {
certObj = new x509.X509Certificate(cleanedCertificatePem);
} catch (error) {
throw new BadRequestError({
message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.`
});
}
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(new Uint8Array(certObj.rawData))
});
const certificateChainPem = submissionResponse.certificateChain || "";
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
plainText: Buffer.from(certificateChainPem)
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(skLeaf)
});
let certificateId: string;
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
caId: ca.id,
profileId,
status: CertStatus.ACTIVE,
friendlyName: commonName,
commonName,
altNames: altNames.join(","),
serialNumber: certObj.serialNumber,
notBefore: certObj.notBefore,
notAfter: certObj.notAfter,
keyUsages,
extendedKeyUsages,
keyAlgorithm,
signatureAlgorithm,
projectId: ca.projectId,
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
},
tx
);
certificateId = cert.id;
if (isRenewal && originalCertificateId) {
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx);
}
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: cert.id,
encryptedPrivateKey
},
tx
);
if (profileId && validity?.ttl && certificateProfileDAL) {
const profile = await certificateProfileDAL.findById(profileId, tx);
if (profile) {
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(undefined, validity.ttl);
if (finalRenewBeforeDays !== undefined) {
await certificateDAL.updateById(
cert.id,
{
renewBeforeDays: finalRenewBeforeDays
},
tx
);
}
}
}
});
return {
certificate: cleanedCertificatePem,
certificateChain: certificateChainPem,
privateKey: skLeaf,
serialNumber: certObj.serialNumber,
certificateId: certificateId!,
ca: azureCa
};
};
const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca || ca.projectId !== projectId) {
@@ -1182,6 +1618,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
updateCertificateAuthority,
listCertificateAuthorities,
orderSubscriberCertificate,
orderCertificateFromProfile,
getTemplates
};
};

View File

@@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({
azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID")
});
export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({
username: z.string(),
password: z.string(),
sslRejectUnauthorized: z.boolean().optional(),
sslCertificate: z.string().optional()
});
export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
type: z.literal(CaType.AZURE_AD_CS),
configuration: AzureAdCsCertificateAuthorityConfigurationSchema

View File

@@ -11,6 +11,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
@@ -62,7 +63,7 @@ type TCertificateAuthorityServiceFactoryDep = {
internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick<
@@ -72,6 +73,7 @@ type TCertificateAuthorityServiceFactoryDep = {
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
};
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
@@ -90,7 +92,8 @@ export const certificateAuthorityServiceFactory = ({
kmsService,
pkiSubscriberDAL,
pkiSyncDAL,
pkiSyncQueue
pkiSyncQueue,
certificateProfileDAL
}: TCertificateAuthorityServiceFactoryDep) => {
const acmeFns = AcmeCertificateAuthorityFns({
appConnectionDAL,
@@ -104,7 +107,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL,
projectDAL,
pkiSyncDAL,
pkiSyncQueue
pkiSyncQueue,
certificateProfileDAL
});
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
@@ -119,7 +123,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL,
projectDAL,
pkiSyncDAL,
pkiSyncQueue
pkiSyncQueue,
certificateProfileDAL
});
const createCertificateAuthority = async (
@@ -487,12 +492,48 @@ export const certificateAuthorityServiceFactory = ({
});
};
const getCaById = async ({
caId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: {
caId: string;
actor: OrgServiceActor["type"];
actorId: string;
actorAuthMethod: OrgServiceActor["authMethod"];
actorOrgId?: string;
}) => {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca) {
throw new NotFoundError({ message: "CA not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateAuthorities
);
return ca;
};
return {
createCertificateAuthority,
findCertificateAuthorityByNameAndProjectId,
listCertificateAuthoritiesByProjectId,
updateCertificateAuthority,
deleteCertificateAuthority,
getAzureAdcsTemplates
getAzureAdcsTemplates,
getCaById
};
};

View File

@@ -0,0 +1,408 @@
import acme from "acme-client";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { QueueJobs, TQueueServiceFactory } from "@app/queue";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service";
import { CertificateRequestStatus } from "../certificate-request/certificate-request-types";
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { CaType } from "./certificate-authority-enums";
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal";
export type TIssueCertificateFromProfileJobData = {
certificateId: string;
profileId: string;
caId: string;
commonName?: string;
altNames?: string[];
ttl: string;
signatureAlgorithm: string;
keyAlgorithm: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
isRenewal?: boolean;
originalCertificateId?: string;
certificateRequestId?: string;
};
type TCertificateIssuanceQueueFactoryDep = {
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: TCertificateDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<
TKmsServiceFactory,
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
queueService: TQueueServiceFactory;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById" | "updateById">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
certificateRequestService?: Pick<
TCertificateRequestServiceFactory,
"attachCertificateToRequest" | "updateCertificateRequestStatus"
>;
};
export type TCertificateIssuanceQueueFactory = ReturnType<typeof certificateIssuanceQueueFactory>;
export const certificateIssuanceQueueFactory = ({
certificateAuthorityDAL,
appConnectionDAL,
appConnectionService,
externalCertificateAuthorityDAL,
certificateDAL,
projectDAL,
kmsService,
queueService,
certificateBodyDAL,
certificateSecretDAL,
pkiSubscriberDAL,
pkiSyncDAL,
pkiSyncQueue,
certificateProfileDAL,
certificateRequestService
}: TCertificateIssuanceQueueFactoryDep) => {
const validateKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
if (!keyUsages) return [];
const validKeyUsages = Object.values(CertKeyUsage);
if (Array.isArray(keyUsages)) {
return keyUsages.filter(
(usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage)
);
}
return [];
};
const validateExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
if (!extendedKeyUsages) return [];
const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage);
if (Array.isArray(extendedKeyUsages)) {
return extendedKeyUsages.filter(
(usage): usage is CertExtendedKeyUsage =>
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)
);
}
return [];
};
const validateKeyAlgorithm = (keyAlgorithm: unknown): CertKeyAlgorithm | undefined => {
if (typeof keyAlgorithm !== "string") return undefined;
const validKeyAlgorithms = Object.values(CertKeyAlgorithm);
return validKeyAlgorithms.includes(keyAlgorithm as CertKeyAlgorithm)
? (keyAlgorithm as CertKeyAlgorithm)
: undefined;
};
const acmeFns = AcmeCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
pkiSubscriberDAL,
projectDAL,
pkiSyncDAL,
pkiSyncQueue,
certificateProfileDAL
});
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
pkiSubscriberDAL,
projectDAL,
pkiSyncDAL,
pkiSyncQueue,
certificateProfileDAL
});
/**
* Queue a certificate issuance job using pgBoss
*/
const queueCertificateIssuance = async ({
certificateId,
profileId,
caId,
commonName,
altNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages,
isRenewal,
originalCertificateId,
certificateRequestId
}: TIssueCertificateFromProfileJobData) => {
const jobData: TIssueCertificateFromProfileJobData = {
certificateId,
profileId,
caId,
commonName,
altNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages,
isRenewal,
originalCertificateId,
certificateRequestId
};
await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, {
retryLimit: 3,
retryDelay: 5,
retryBackoff: true
});
};
/**
* Process certificate issuance jobs
*/
const processCertificateIssuanceJobs = async (data: TIssueCertificateFromProfileJobData) => {
const {
certificateId,
profileId,
caId,
commonName,
altNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages,
isRenewal,
originalCertificateId,
certificateRequestId
} = data;
try {
logger.info(`Processing certificate issuance job for [certificateId=${certificateId}] [caId=${caId}]`);
if (!caId) {
throw new NotFoundError({
message: `Certificate authority ID is required for external CA certificate issuance`
});
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (ca.externalCa?.type === CaType.ACME) {
const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm);
if (!validatedKeyAlgorithm) {
throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` });
}
const keyAlg = keyAlgorithmToAlgCfg(validatedKeyAlgorithm);
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]);
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const [, certificateCsr] = await acme.crypto.createCsr(
{
altNames: altNames || [],
commonName: commonName || ""
},
skLeaf
);
const acmeResult = await acmeFns.orderCertificateFromProfile({
caId,
profileId,
commonName: commonName || "",
altNames: altNames || [],
csr: certificateCsr,
csrPrivateKey: skLeaf,
keyUsages: validateKeyUsages(keyUsages),
extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages),
ttl,
signatureAlgorithm,
keyAlgorithm,
isRenewal,
originalCertificateId
});
if (certificateRequestId && certificateRequestService && acmeResult?.id) {
try {
await certificateRequestService.attachCertificateToRequest({
certificateRequestId,
certificateId: acmeResult.id
});
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
} catch (attachError) {
logger.error(
attachError,
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
);
try {
await certificateRequestService.updateCertificateRequestStatus({
certificateRequestId,
status: CertificateRequestStatus.FAILED,
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
});
} catch (statusUpdateError) {
logger.error(
statusUpdateError,
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
);
}
}
}
} else if (ca.externalCa?.type === CaType.AZURE_AD_CS) {
let template: string | undefined;
if (certificateProfileDAL) {
try {
const profile = await certificateProfileDAL.findById(profileId);
if (
profile?.externalConfigs &&
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null
) {
const configs = profile.externalConfigs;
if (typeof configs.template === "string") {
template = configs.template;
}
}
} catch (error) {
logger.warn(
`Failed to fetch profile ${profileId} for template extraction: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm);
if (!validatedKeyAlgorithm) {
throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` });
}
const azureParams = {
caId,
profileId,
commonName: commonName || "",
altNames: altNames || [],
keyUsages: validateKeyUsages(keyUsages),
extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages),
validity: { ttl },
signatureAlgorithm,
keyAlgorithm: validatedKeyAlgorithm,
isRenewal,
originalCertificateId,
template
};
const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams);
if (certificateRequestId && certificateRequestService && azureResult?.certificateId) {
try {
await certificateRequestService.attachCertificateToRequest({
certificateRequestId,
certificateId: azureResult.certificateId
});
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
} catch (attachError) {
logger.error(
attachError,
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
);
try {
await certificateRequestService.updateCertificateRequestStatus({
certificateRequestId,
status: CertificateRequestStatus.FAILED,
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
});
} catch (statusUpdateError) {
logger.error(
statusUpdateError,
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
);
}
}
}
}
logger.info(
`Successfully processed certificate issuance job with [certificateId=${certificateId}] [caId=${caId}]`
);
} catch (error: unknown) {
logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`);
if (certificateRequestId && certificateRequestService) {
try {
await certificateRequestService.updateCertificateRequestStatus({
certificateRequestId,
status: CertificateRequestStatus.FAILED,
errorMessage: `Certificate issuance failed: ${error instanceof Error ? error.message : String(error)}`
});
logger.info(`Updated certificate request ${certificateRequestId} status to failed due to issuance error`);
} catch (statusUpdateError) {
logger.error(
statusUpdateError,
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
);
}
}
throw error;
}
};
const initializeCertificateIssuanceQueue = async () => {
const appCfg = getConfig();
await queueService.startPg(
QueueJobs.CaIssueCertificateFromProfile,
async ([job]) => {
const data = job.data as TIssueCertificateFromProfileJobData;
await processCertificateIssuanceJobs(data);
},
{
workerCount: appCfg.NODE_ENV === "production" ? 3 : 1,
batchSize: 1
}
);
logger.info("Certificate issuance queue worker initialized successfully");
};
return {
queueCertificateIssuance,
initializeCertificateIssuanceQueue,
processCertificateIssuanceJobs
};
};

View File

@@ -22,10 +22,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => {
try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [
TCertificateProfile
];
return certificateProfile;
const dataToInsert = {
...data,
externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null
};
const [insertedProfile] = await (tx || db)(TableName.PkiCertificateProfile).insert(dataToInsert).returning("*");
return {
...insertedProfile,
externalConfigs: insertedProfile.externalConfigs
? (JSON.parse(insertedProfile.externalConfigs) as Record<string, unknown>)
: null
} as TCertificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Create certificate profile" });
}
@@ -33,11 +42,25 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise<TCertificateProfile> => {
try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile)
const dataToUpdate: Partial<Record<string, unknown>> = {
...data
};
if (data.externalConfigs !== undefined) {
dataToUpdate.externalConfigs = data.externalConfigs ? JSON.stringify(data.externalConfigs) : null;
}
const [updatedProfile] = await (tx || db)(TableName.PkiCertificateProfile)
.where({ id })
.update(data)
.returning("*")) as [TCertificateProfile];
return certificateProfile;
.update(dataToUpdate)
.returning("*");
return {
...updatedProfile,
externalConfigs: updatedProfile.externalConfigs
? (JSON.parse(updatedProfile.externalConfigs) as Record<string, unknown>)
: null
} as TCertificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
@@ -57,10 +80,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const findById = async (id: string, tx?: Knex): Promise<TCertificateProfile | undefined> => {
try {
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as
| TCertificateProfile
| undefined;
return certificateProfile;
const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first();
if (!certificateProfile) return undefined;
return {
...certificateProfile,
externalConfigs: certificateProfile.externalConfigs
? (JSON.parse(certificateProfile.externalConfigs) as Record<string, unknown>)
: null
} as TCertificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by id" });
}
@@ -203,6 +232,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId,
externalConfigs: result.externalConfigs
? (JSON.parse(result.externalConfigs) as Record<string, unknown>)
: null,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
@@ -277,6 +309,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
}
const query = baseQuery
.leftJoin(
TableName.CertificateAuthority,
`${TableName.PkiCertificateProfile}.caId`,
`${TableName.CertificateAuthority}.id`
)
.leftJoin(
TableName.ExternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.caId`
)
.leftJoin(
TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`,
@@ -294,6 +336,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
)
.select(selectAllTableCols(TableName.PkiCertificateProfile))
.select(
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"),
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalCaType"),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
@@ -337,6 +384,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
}
: undefined;
const certificateAuthority = result.caId
? {
id: result.caId as string,
name: result.caName as string,
status: result.caStatus as string,
isExternal: !!result.externalCaId,
externalType: result.externalCaType as string | undefined
}
: undefined;
const baseProfile = {
id: result.id,
projectId: result.projectId,
@@ -349,11 +406,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId,
externalConfigs: result.externalConfigs
? (JSON.parse(result.externalConfigs as string) as Record<string, unknown>)
: null,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
apiConfig,
acmeConfig
acmeConfig,
certificateAuthority
};
return baseProfile as TCertificateProfileWithConfigs;

View File

@@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { ActorType, AuthMethod } from "../auth/auth-type";
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import type { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
@@ -100,6 +100,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfigId: "api-config-123",
estConfigId: null,
externalConfigs: null,
createdAt: new Date(),
updatedAt: new Date()
};
@@ -229,17 +230,10 @@ describe("CertificateProfileService", () => {
delete: vi.fn()
} as unknown as TCertificateAuthorityDALFactory;
const mockCertificateAuthorityCertDAL = {
create: vi.fn(),
const mockExternalCertificateAuthorityDAL = {
findById: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateAuthorityCertDALFactory;
findOne: vi.fn()
} as unknown as Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
beforeEach(() => {
vi.spyOn(ForbiddenError, "from").mockReturnValue({
@@ -261,7 +255,7 @@ describe("CertificateProfileService", () => {
certificateBodyDAL: mockCertificateBodyDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL,
permissionService: mockPermissionService,
licenseService: mockLicenseService,
kmsService: mockKmsService,

View File

@@ -19,8 +19,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { CaType } from "../certificate-authority/certificate-authority-enums";
import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
@@ -68,6 +69,55 @@ const validateIssuerTypeConstraints = (
}
};
const validateTemplateByExternalCaType = (
externalCaType: CaType | undefined,
externalConfigs: Record<string, unknown> | null | undefined
) => {
if (!externalCaType) return;
switch (externalCaType) {
case CaType.AZURE_AD_CS:
if (!externalConfigs?.template || typeof externalConfigs.template !== "string") {
throw new ForbiddenRequestError({
message: "Azure ADCS Certificate Authority requires a template to be specified in external configs"
});
}
break;
default:
break;
}
};
const validateExternalConfigs = async (
externalConfigs: Record<string, unknown> | null | undefined,
caId: string | null,
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">,
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findOne">
) => {
if (!externalConfigs) return;
if (!caId) {
throw new ForbiddenRequestError({
message: "External configs can only be specified when a Certificate Authority is selected"
});
}
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
const externalCa = await externalCertificateAuthorityDAL.findOne({ caId });
if (!externalCa) {
throw new ForbiddenRequestError({
message: "External configs can only be specified for external Certificate Authorities"
});
}
validateTemplateByExternalCaType(externalCa.type as CaType, externalConfigs);
};
const generateAndEncryptAcmeEabSecret = async (
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
@@ -180,7 +230,7 @@ type TCertificateProfileServiceFactoryDep = {
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
@@ -190,10 +240,22 @@ type TCertificateProfileServiceFactoryDep = {
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
let parsedExternalConfigs: Record<string, unknown> | null = null;
if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "string") {
try {
parsedExternalConfigs = JSON.parse(dalResult.externalConfigs) as Record<string, unknown>;
} catch {
parsedExternalConfigs = null;
}
} else if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "object") {
parsedExternalConfigs = dalResult.externalConfigs as Record<string, unknown>;
}
return {
...dalResult,
enrollmentType: dalResult.enrollmentType as EnrollmentType,
issuerType: dalResult.issuerType as IssuerType
issuerType: dalResult.issuerType as IssuerType,
externalConfigs: parsedExternalConfigs
} as TCertificateProfile;
};
@@ -205,6 +267,8 @@ export const certificateProfileServiceFactory = ({
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
permissionService,
licenseService,
kmsService,
@@ -272,6 +336,14 @@ export const certificateProfileServiceFactory = ({
validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null);
// Validate external configs
await validateExternalConfigs(
data.externalConfigs,
data.caId ?? null,
certificateAuthorityDAL,
externalCertificateAuthorityDAL
);
// Validate enrollment configuration requirements
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
throw new ForbiddenRequestError({
@@ -340,7 +412,8 @@ export const certificateProfileServiceFactory = ({
projectId,
estConfigId,
apiConfigId,
acmeConfigId
acmeConfigId,
externalConfigs: data.externalConfigs
},
tx
);
@@ -414,6 +487,16 @@ export const certificateProfileServiceFactory = ({
validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId);
// Validate external configs only if they are provided in the update
if (data.externalConfigs !== undefined) {
await validateExternalConfigs(
data.externalConfigs,
finalCaId ?? null,
certificateAuthorityDAL,
externalCertificateAuthorityDAL
);
}
const updatedData =
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data;
@@ -558,9 +641,24 @@ export const certificateProfileServiceFactory = ({
}
}
// Parse externalConfigs from JSON string to object if it exists
let parsedExternalConfigs: Record<string, unknown> | null = null;
if (profile.externalConfigs && typeof profile.externalConfigs === "string") {
try {
parsedExternalConfigs = JSON.parse(profile.externalConfigs) as Record<string, unknown>;
} catch {
// If parsing fails, leave as null
parsedExternalConfigs = null;
}
} else if (profile.externalConfigs && typeof profile.externalConfigs === "object") {
// Already an object, use as-is
parsedExternalConfigs = profile.externalConfigs;
}
return {
...profile,
enrollmentType: profile.enrollmentType as EnrollmentType
enrollmentType: profile.enrollmentType as EnrollmentType,
externalConfigs: parsedExternalConfigs
};
};

View File

@@ -15,19 +15,28 @@ export enum IssuerType {
SELF_SIGNED = "self-signed"
}
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType"> & {
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType" | "externalConfigs"> & {
enrollmentType: EnrollmentType;
issuerType: IssuerType;
externalConfigs?: Record<string, unknown> | null;
};
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType" | "issuerType"> & {
export type TCertificateProfileInsert = Omit<
TPkiCertificateProfilesInsert,
"enrollmentType" | "issuerType" | "externalConfigs"
> & {
enrollmentType: EnrollmentType;
issuerType: IssuerType;
externalConfigs?: Record<string, unknown> | null;
};
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType" | "issuerType"> & {
export type TCertificateProfileUpdate = Omit<
TPkiCertificateProfilesUpdate,
"enrollmentType" | "issuerType" | "externalConfigs"
> & {
enrollmentType?: EnrollmentType;
issuerType?: IssuerType;
externalConfigs?: Record<string, unknown> | null;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;
@@ -47,9 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
};
certificateAuthority?: {
id: string;
projectId: string;
projectId?: string;
status: string;
name: string;
isExternal?: boolean;
externalType?: string;
};
certificateTemplate?: {
id: string;

View File

@@ -0,0 +1,144 @@
import { TDbClient } from "@app/db";
import { TableName, TCertificateRequests } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
type TCertificateRequestWithCertificateFlat = TCertificateRequests & {
certificateId?: string | null;
certificateSerialNumber?: string | null;
certificateFriendlyName?: string | null;
certificateCommonName?: string | null;
certificateAltNames?: string | null;
certificateStatus?: string | null;
certificateNotBefore?: Date | null;
certificateNotAfter?: Date | null;
certificateKeyUsages?: string[] | null;
certificateExtendedKeyUsages?: string[] | null;
};
type TCertificateInfo = {
id: string;
serialNumber: string;
friendlyName: string | null;
commonName: string;
altNames: string | null;
status: string;
notBefore: Date;
notAfter: Date;
keyUsages: string[] | null;
extendedKeyUsages: string[] | null;
};
type TCertificateRequestWithCertificate = TCertificateRequests & {
certificate: TCertificateInfo | null;
};
export type TCertificateRequestDALFactory = ReturnType<typeof certificateRequestDALFactory>;
export const certificateRequestDALFactory = (db: TDbClient) => {
const certificateRequestOrm = ormify(db, TableName.CertificateRequests);
const findByIdWithCertificate = async (id: string): Promise<TCertificateRequestWithCertificate | null> => {
try {
const certificateRequest = (await db(TableName.CertificateRequests)
.leftJoin(
TableName.Certificate,
`${TableName.CertificateRequests}.certificateId`,
`${TableName.Certificate}.id`
)
.where(`${TableName.CertificateRequests}.id`, id)
.select(
`${TableName.CertificateRequests}.*`,
`${TableName.Certificate}.id as certificateId`,
`${TableName.Certificate}.serialNumber as certificateSerialNumber`,
`${TableName.Certificate}.friendlyName as certificateFriendlyName`,
`${TableName.Certificate}.commonName as certificateCommonName`,
`${TableName.Certificate}.altNames as certificateAltNames`,
`${TableName.Certificate}.status as certificateStatus`,
`${TableName.Certificate}.notBefore as certificateNotBefore`,
`${TableName.Certificate}.notAfter as certificateNotAfter`,
`${TableName.Certificate}.keyUsages as certificateKeyUsages`,
`${TableName.Certificate}.extendedKeyUsages as certificateExtendedKeyUsages`
)
.first()) as TCertificateRequestWithCertificateFlat | undefined;
if (!certificateRequest) return null;
// Transform the flat result into nested structure
const {
certificateId,
certificateSerialNumber,
certificateFriendlyName,
certificateCommonName,
certificateAltNames,
certificateStatus,
certificateNotBefore,
certificateNotAfter,
certificateKeyUsages,
certificateExtendedKeyUsages,
...certificateRequestData
} = certificateRequest;
return {
...certificateRequestData,
certificate: certificateId
? {
id: certificateId,
serialNumber: certificateSerialNumber as string,
friendlyName: certificateFriendlyName || null,
commonName: certificateCommonName as string,
altNames: certificateAltNames || null,
status: certificateStatus as string,
notBefore: certificateNotBefore as Date,
notAfter: certificateNotAfter as Date,
keyUsages: certificateKeyUsages || null,
extendedKeyUsages: certificateExtendedKeyUsages || null
}
: null
};
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" });
}
};
const findPendingByProjectId = async (projectId: string): Promise<TCertificateRequests[]> => {
try {
return (await db(TableName.CertificateRequests)
.where({ projectId, status: "pending" })
.orderBy("createdAt", "desc")) as TCertificateRequests[];
} catch (error) {
throw new DatabaseError({ error, name: "Find pending certificate requests by project ID" });
}
};
const updateStatus = async (id: string, status: string, errorMessage?: string): Promise<TCertificateRequests> => {
try {
const updateData: Partial<TCertificateRequests> = { status };
if (errorMessage !== undefined) {
updateData.errorMessage = errorMessage;
}
return await certificateRequestOrm.updateById(id, updateData);
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate request status" });
}
};
const attachCertificate = async (id: string, certificateId: string): Promise<TCertificateRequests> => {
try {
return await certificateRequestOrm.updateById(id, {
certificateId,
status: "issued"
});
} catch (error) {
throw new DatabaseError({ error, name: "Attach certificate to request" });
}
};
return {
...certificateRequestOrm,
findByIdWithCertificate,
findPendingByProjectId,
updateStatus,
attachCertificate
};
};

View File

@@ -0,0 +1,561 @@
/* 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 */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { createMongoAbility, ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSet,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType, AuthMethod } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
import { certificateRequestServiceFactory, TCertificateRequestServiceFactory } from "./certificate-request-service";
import { CertificateRequestStatus } from "./certificate-request-types";
describe("CertificateRequestService", () => {
let service: TCertificateRequestServiceFactory;
const mockCertificateRequestDAL: Pick<
TCertificateRequestDALFactory,
"create" | "findById" | "findByIdWithCertificate" | "updateStatus" | "attachCertificate"
> = {
create: vi.fn() as any,
findById: vi.fn() as any,
findByIdWithCertificate: vi.fn() as any,
updateStatus: vi.fn() as any,
attachCertificate: vi.fn() as any
};
const mockCertificateDAL: Pick<TCertificateDALFactory, "findById"> = {
findById: vi.fn() as any
};
const mockCertificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey"> = {
getCertBody: vi.fn() as any,
getCertPrivateKey: vi.fn() as any
};
const mockPermissionService: Pick<TPermissionServiceFactory, "getProjectPermission"> = {
getProjectPermission: vi.fn() as any
};
beforeEach(() => {
vi.clearAllMocks();
service = certificateRequestServiceFactory({
certificateRequestDAL: mockCertificateRequestDAL as TCertificateRequestDALFactory,
certificateDAL: mockCertificateDAL,
certificateService: mockCertificateService,
permissionService: mockPermissionService
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe("createCertificateRequest", () => {
const mockCreateData = {
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
projectId: "550e8400-e29b-41d4-a716-446655440003",
profileId: "550e8400-e29b-41d4-a716-446655440004",
commonName: "test.example.com"
};
it("should create certificate request successfully", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Create,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockCreatedRequest = {
id: "550e8400-e29b-41d4-a716-446655440005",
status: CertificateRequestStatus.PENDING,
projectId: "550e8400-e29b-41d4-a716-446655440003",
profileId: "550e8400-e29b-41d4-a716-446655440004",
commonName: "test.example.com"
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.create as any).mockResolvedValue(mockCreatedRequest);
const result = await service.createCertificateRequest(mockCreateData);
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
projectId: "550e8400-e29b-41d4-a716-446655440003",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
actionProjectType: ActionProjectType.CertificateManager
});
expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith(
expect.objectContaining({
status: CertificateRequestStatus.PENDING,
projectId: "550e8400-e29b-41d4-a716-446655440003",
profileId: "550e8400-e29b-41d4-a716-446655440004",
commonName: "test.example.com"
})
);
expect(result).toEqual(mockCreatedRequest);
});
it("should throw ForbiddenError when user lacks permission", async () => {
const mockPermission = {
permission: ForbiddenError.from(createMongoAbility([]))
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
await expect(service.createCertificateRequest(mockCreateData)).rejects.toThrow();
});
});
describe("getCertificateRequest", () => {
const mockGetData = {
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
projectId: "550e8400-e29b-41d4-a716-446655440003",
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
};
it("should get certificate request successfully", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440005",
projectId: "550e8400-e29b-41d4-a716-446655440003",
status: CertificateRequestStatus.PENDING
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
const result = await service.getCertificateRequest(mockGetData);
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
projectId: "550e8400-e29b-41d4-a716-446655440003",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
actionProjectType: ActionProjectType.CertificateManager
});
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440005");
expect(result).toEqual(mockRequest);
});
it("should throw NotFoundError when certificate request does not exist", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError);
});
it("should throw BadRequestError when certificate request belongs to different project", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440005",
projectId: "550e8400-e29b-41d4-a716-446655440099",
status: CertificateRequestStatus.PENDING
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(BadRequestError);
});
});
describe("getCertificateFromRequest", () => {
const mockGetData = {
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
projectId: "550e8400-e29b-41d4-a716-446655440003",
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
};
it("should get certificate from request successfully when certificate is attached", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockCertificate = {
id: "550e8400-e29b-41d4-a716-446655440006",
serialNumber: "123456",
commonName: "test.example.com"
};
const mockRequestWithCert = {
id: "550e8400-e29b-41d4-a716-446655440005",
projectId: "550e8400-e29b-41d4-a716-446655440003",
status: CertificateRequestStatus.ISSUED,
certificate: mockCertificate,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date()
};
const mockCertBody = {
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
};
const mockPrivateKey = {
certPrivateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----"
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
(mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey);
const result = await service.getCertificateFromRequest(mockGetData);
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440005"
);
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
serialNumber: "123456",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
serialNumber: "123456",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(result).toEqual({
status: CertificateRequestStatus.ISSUED,
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----",
serialNumber: "123456",
errorMessage: null,
createdAt: mockRequestWithCert.createdAt,
updatedAt: mockRequestWithCert.updatedAt
});
});
it("should get certificate from request successfully when no certificate is attached", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockRequestWithoutCert = {
id: "550e8400-e29b-41d4-a716-446655440007",
projectId: "550e8400-e29b-41d4-a716-446655440003",
status: CertificateRequestStatus.PENDING,
certificate: null,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date()
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert);
const result = await service.getCertificateFromRequest(mockGetData);
expect(result).toEqual({
status: CertificateRequestStatus.PENDING,
certificate: null,
privateKey: null,
serialNumber: null,
errorMessage: null,
createdAt: mockRequestWithoutCert.createdAt,
updatedAt: mockRequestWithoutCert.updatedAt
});
});
it("should get certificate from request successfully when private key access is denied", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockCertificate = {
id: "550e8400-e29b-41d4-a716-446655440008",
serialNumber: "123456",
commonName: "test.example.com"
};
const mockRequestWithCert = {
id: "550e8400-e29b-41d4-a716-446655440005",
projectId: "550e8400-e29b-41d4-a716-446655440003",
status: CertificateRequestStatus.ISSUED,
certificate: mockCertificate,
errorMessage: null,
createdAt: new Date(),
updatedAt: new Date()
};
const mockCertBody = {
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
(mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key access denied"));
const result = await service.getCertificateFromRequest(mockGetData);
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440005"
);
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
serialNumber: "123456",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
serialNumber: "123456",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(result).toEqual({
status: CertificateRequestStatus.ISSUED,
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
privateKey: null,
serialNumber: "123456",
errorMessage: null,
createdAt: mockRequestWithCert.createdAt,
updatedAt: mockRequestWithCert.updatedAt
});
});
it("should get certificate from request with error message when failed", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
const mockFailedRequest = {
id: "550e8400-e29b-41d4-a716-446655440010",
projectId: "550e8400-e29b-41d4-a716-446655440003",
status: CertificateRequestStatus.FAILED,
certificate: null,
errorMessage: "Certificate issuance failed",
createdAt: new Date(),
updatedAt: new Date()
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest);
const result = await service.getCertificateFromRequest(mockGetData);
expect(result).toEqual({
status: CertificateRequestStatus.FAILED,
certificate: null,
privateKey: null,
serialNumber: null,
errorMessage: "Certificate issuance failed",
createdAt: mockFailedRequest.createdAt,
updatedAt: mockFailedRequest.updatedAt
});
});
it("should throw NotFoundError when certificate request does not exist", async () => {
const mockPermission = {
permission: createMongoAbility<ProjectPermissionSet>([
{
action: ProjectPermissionCertificateActions.Read,
subject: ProjectPermissionSub.Certificates
}
])
};
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(null);
await expect(service.getCertificateFromRequest(mockGetData)).rejects.toThrow(NotFoundError);
});
});
describe("updateCertificateRequestStatus", () => {
it("should update certificate request status successfully", async () => {
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440011",
status: CertificateRequestStatus.PENDING
};
const mockUpdatedRequest = {
id: "550e8400-e29b-41d4-a716-446655440011",
status: CertificateRequestStatus.ISSUED
};
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
const result = await service.updateCertificateRequestStatus({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440011",
status: CertificateRequestStatus.ISSUED
});
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440011");
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440011",
CertificateRequestStatus.ISSUED,
undefined
);
expect(result).toEqual(mockUpdatedRequest);
});
it("should update certificate request status with error message", async () => {
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440012",
status: CertificateRequestStatus.PENDING
};
const mockUpdatedRequest = {
id: "550e8400-e29b-41d4-a716-446655440012",
status: CertificateRequestStatus.FAILED
};
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
const result = await service.updateCertificateRequestStatus({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440012",
status: CertificateRequestStatus.FAILED,
errorMessage: "Certificate issuance failed"
});
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440012",
CertificateRequestStatus.FAILED,
"Certificate issuance failed"
);
expect(result).toEqual(mockUpdatedRequest);
});
it("should throw NotFoundError when certificate request does not exist", async () => {
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
await expect(
service.updateCertificateRequestStatus({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440013",
status: CertificateRequestStatus.ISSUED
})
).rejects.toThrow(NotFoundError);
});
});
describe("attachCertificateToRequest", () => {
it("should attach certificate to request successfully", async () => {
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440014",
status: CertificateRequestStatus.PENDING
};
const mockCertificate = {
id: "550e8400-e29b-41d4-a716-446655440015"
};
const mockUpdatedRequest = {
id: "550e8400-e29b-41d4-a716-446655440014",
status: CertificateRequestStatus.ISSUED,
certificateId: "550e8400-e29b-41d4-a716-446655440015"
};
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
(mockCertificateDAL.findById as any).mockResolvedValue(mockCertificate);
(mockCertificateRequestDAL.attachCertificate as any).mockResolvedValue(mockUpdatedRequest);
const result = await service.attachCertificateToRequest({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440014",
certificateId: "550e8400-e29b-41d4-a716-446655440015"
});
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440014");
expect(mockCertificateDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440015");
expect(mockCertificateRequestDAL.attachCertificate).toHaveBeenCalledWith(
"550e8400-e29b-41d4-a716-446655440014",
"550e8400-e29b-41d4-a716-446655440015"
);
expect(result).toEqual(mockUpdatedRequest);
});
it("should throw NotFoundError when certificate request does not exist", async () => {
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
await expect(
service.attachCertificateToRequest({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440016",
certificateId: "550e8400-e29b-41d4-a716-446655440017"
})
).rejects.toThrow(NotFoundError);
});
it("should throw NotFoundError when certificate does not exist", async () => {
const mockRequest = {
id: "550e8400-e29b-41d4-a716-446655440018",
status: CertificateRequestStatus.PENDING
};
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
(mockCertificateDAL.findById as any).mockResolvedValue(null);
await expect(
service.attachCertificateToRequest({
certificateRequestId: "550e8400-e29b-41d4-a716-446655440018",
certificateId: "550e8400-e29b-41d4-a716-446655440019"
})
).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,275 @@
import { ForbiddenError } from "@casl/ability";
import { z } from "zod";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
import {
CertificateRequestStatus,
TAttachCertificateToRequestDTO,
TCreateCertificateRequestDTO,
TGetCertificateFromRequestDTO,
TGetCertificateRequestDTO,
TUpdateCertificateRequestStatusDTO
} from "./certificate-request-types";
type TCertificateRequestServiceFactoryDep = {
certificateRequestDAL: TCertificateRequestDALFactory;
certificateDAL: Pick<TCertificateDALFactory, "findById">;
certificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateRequestServiceFactory = ReturnType<typeof certificateRequestServiceFactory>;
// Input validation schemas
const certificateRequestDataSchema = z
.object({
profileId: z.string().uuid().optional(),
caId: z.string().uuid().optional(),
csr: z.string().min(1).optional(),
commonName: z.string().min(1).max(255).optional(),
altNames: z.string().max(1000).optional(),
keyUsages: z.array(z.string()).max(20).optional(),
extendedKeyUsages: z.array(z.string()).max(20).optional(),
notBefore: z.date().optional(),
notAfter: z.date().optional(),
keyAlgorithm: z.string().max(100).optional(),
signatureAlgorithm: z.string().max(100).optional(),
metadata: z.string().max(2000).optional()
})
.refine(
(data) => {
// Must have either profileId or caId
return data.profileId || data.caId;
},
{
message: "Either profileId or caId must be provided"
}
)
.refine(
(data) => {
// If notAfter is provided, it must be after notBefore
if (data.notBefore && data.notAfter) {
return data.notAfter > data.notBefore;
}
return true;
},
{
message: "notAfter must be after notBefore"
}
);
const validateCertificateRequestData = (data: unknown) => {
try {
return certificateRequestDataSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
throw new BadRequestError({
message: `Invalid certificate request data: ${error.errors.map((e) => e.message).join(", ")}`
});
}
throw error;
}
};
export const certificateRequestServiceFactory = ({
certificateRequestDAL,
certificateDAL,
certificateService,
permissionService
}: TCertificateRequestServiceFactoryDep) => {
const createCertificateRequest = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
...requestData
}: TCreateCertificateRequestDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
// Validate input data before creating the request
const validatedData = validateCertificateRequestData(requestData);
const certificateRequest = await certificateRequestDAL.create({
status: CertificateRequestStatus.PENDING,
projectId,
...validatedData
});
return certificateRequest;
};
const getCertificateRequest = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
certificateRequestId
}: TGetCertificateRequestDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
if (!certificateRequest) {
throw new NotFoundError({ message: "Certificate request not found" });
}
if (certificateRequest.projectId !== projectId) {
throw new BadRequestError({ message: "Certificate request does not belong to this project" });
}
return certificateRequest;
};
const getCertificateFromRequest = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
certificateRequestId
}: TGetCertificateFromRequestDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId);
if (!certificateRequest) {
throw new NotFoundError({ message: "Certificate request not found" });
}
if (certificateRequest.projectId !== projectId) {
throw new BadRequestError({ message: "Certificate request does not belong to this project" });
}
// If no certificate is attached, return basic info
if (!certificateRequest.certificate) {
return {
status: certificateRequest.status as CertificateRequestStatus,
certificate: null,
privateKey: null,
serialNumber: null,
errorMessage: certificateRequest.errorMessage || null,
createdAt: certificateRequest.createdAt,
updatedAt: certificateRequest.updatedAt
};
}
// Get certificate body (PEM data)
const certBody = await certificateService.getCertBody({
serialNumber: certificateRequest.certificate.serialNumber,
actor,
actorId,
actorAuthMethod,
actorOrgId
});
// Try to get private key (may fail if user doesn't have permission)
let privateKey: string | null = null;
try {
const certPrivateKey = await certificateService.getCertPrivateKey({
serialNumber: certificateRequest.certificate.serialNumber,
actor,
actorId,
actorAuthMethod,
actorOrgId
});
privateKey = certPrivateKey.certPrivateKey;
} catch (error) {
// Private key access denied - continue without it
privateKey = null;
}
return {
status: certificateRequest.status as CertificateRequestStatus,
certificate: certBody.certificate,
privateKey,
serialNumber: certificateRequest.certificate.serialNumber,
errorMessage: certificateRequest.errorMessage || null,
createdAt: certificateRequest.createdAt,
updatedAt: certificateRequest.updatedAt
};
};
const updateCertificateRequestStatus = async ({
certificateRequestId,
status,
errorMessage
}: TUpdateCertificateRequestStatusDTO) => {
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
if (!certificateRequest) {
throw new NotFoundError({ message: "Certificate request not found" });
}
return certificateRequestDAL.updateStatus(certificateRequestId, status, errorMessage);
};
const attachCertificateToRequest = async ({
certificateRequestId,
certificateId
}: TAttachCertificateToRequestDTO) => {
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
if (!certificateRequest) {
throw new NotFoundError({ message: "Certificate request not found" });
}
const certificate = await certificateDAL.findById(certificateId);
if (!certificate) {
throw new NotFoundError({ message: "Certificate not found" });
}
return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId);
};
return {
createCertificateRequest,
getCertificateRequest,
getCertificateFromRequest,
updateCertificateRequestStatus,
attachCertificateToRequest
};
};

View File

@@ -0,0 +1,41 @@
import { TProjectPermission } from "@app/lib/types";
export enum CertificateRequestStatus {
PENDING = "pending",
ISSUED = "issued",
FAILED = "failed"
}
export type TCreateCertificateRequestDTO = TProjectPermission & {
profileId?: string;
caId?: string;
csr?: string;
commonName?: string;
altNames?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
notBefore?: Date;
notAfter?: Date;
keyAlgorithm?: string;
signatureAlgorithm?: string;
metadata?: string;
};
export type TGetCertificateRequestDTO = TProjectPermission & {
certificateRequestId: string;
};
export type TGetCertificateFromRequestDTO = TProjectPermission & {
certificateRequestId: string;
};
export type TUpdateCertificateRequestStatusDTO = {
certificateRequestId: string;
status: CertificateRequestStatus;
errorMessage?: string;
};
export type TAttachCertificateToRequestDTO = {
certificateRequestId: string;
certificateId: string;
};

View File

@@ -0,0 +1,40 @@
import RE2 from "re2";
import { BadRequestError } from "@app/lib/errors";
export const parseTtlToDays = (ttl: string): number => {
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
if (!match) {
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
}
const [, value, unit] = match;
const num = parseInt(value, 10);
switch (unit) {
case "d":
return num;
case "h":
return Math.ceil(num / 24);
case "m":
return Math.ceil(num / (24 * 60));
default:
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
}
};
export const calculateRenewalThreshold = (
profileRenewBeforeDays: number | undefined,
certificateTtlInDays: number
): number | undefined => {
if (profileRenewBeforeDays === undefined) {
return undefined;
}
if (profileRenewBeforeDays >= certificateTtlInDays) {
// If renewBeforeDays >= TTL, renew 1 day before expiry
return Math.max(1, certificateTtlInDays - 1);
}
return profileRenewBeforeDays;
};

View File

@@ -42,7 +42,7 @@ describe("CertificateV3Service", () => {
const mockCertificateDAL: Pick<
TCertificateDALFactory,
"findOne" | "findById" | "updateById" | "transaction" | "create"
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
> = {
findOne: vi.fn(),
findById: vi.fn(),
@@ -57,7 +57,8 @@ describe("CertificateV3Service", () => {
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
})
}),
find: vi.fn().mockResolvedValue([])
};
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create"> = {
@@ -65,12 +66,24 @@ describe("CertificateV3Service", () => {
create: vi.fn()
};
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
findByIdWithAssociatedCa: vi.fn()
const mockCertificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "create" | "updateById" | "findById" | "transaction" | "findWithAssociatedCa"
> = {
findByIdWithAssociatedCa: vi.fn(),
create: vi.fn().mockResolvedValue({ id: "ca-123" }),
updateById: vi.fn().mockResolvedValue({ id: "ca-123" }),
findById: vi.fn().mockResolvedValue({ id: "ca-123" }),
findWithAssociatedCa: vi.fn().mockResolvedValue([]),
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
})
};
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs"> = {
findByIdWithConfigs: vi.fn()
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById"> = {
findByIdWithConfigs: vi.fn(),
findById: vi.fn()
};
const mockCertificateTemplateV2Service: Pick<
@@ -168,7 +181,11 @@ describe("CertificateV3Service", () => {
kmsService: {
generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"),
encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))),
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted")))
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))),
createCipherPairWithDataKey: vi.fn().mockResolvedValue({
cipherTextBlob: Buffer.from("encrypted"),
plainTextKey: Buffer.from("plainkey")
})
},
projectDAL: {
findOne: vi.fn().mockResolvedValue({ id: "project-123" }),
@@ -178,7 +195,10 @@ describe("CertificateV3Service", () => {
const mockTx = {};
return callback(mockTx);
})
} as any
} as any,
certificateIssuanceQueue: {
queueCertificateIssuance: vi.fn().mockResolvedValue(undefined)
}
});
});

View File

@@ -40,7 +40,11 @@ import {
} 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 { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types";
import {
EnrollmentType,
IssuerType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -85,11 +89,17 @@ import {
} from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction" | "create">;
certificateDAL: Pick<
TCertificateDALFactory,
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById"
>;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById">;
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
certificateTemplateV2Service: Pick<
TCertificateTemplateV2ServiceFactory,
@@ -103,8 +113,15 @@ type TCertificateV3ServiceFactoryDep = {
>;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
kmsService: Pick<
TKmsServiceFactory,
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
>;
projectDAL: TProjectDALFactory;
certificateIssuanceQueue: Pick<
import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory,
"queueCertificateIssuance"
>;
};
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
@@ -294,14 +311,57 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
if (!keyUsages) return [];
if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[];
return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage);
const validKeyUsages = Object.values(CertKeyUsage);
if (Array.isArray(keyUsages)) {
return keyUsages.filter(
(usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage)
);
}
if (typeof keyUsages === "string") {
return keyUsages
.split(",")
.map((usage) => usage.trim())
.filter((usage): usage is CertKeyUsage => validKeyUsages.includes(usage as CertKeyUsage));
}
return [];
};
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
if (!extendedKeyUsages) return [];
if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[];
return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage);
const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage);
if (Array.isArray(extendedKeyUsages)) {
return extendedKeyUsages.filter(
(usage): usage is CertExtendedKeyUsage =>
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)
);
}
if (typeof extendedKeyUsages === "string") {
return extendedKeyUsages
.split(",")
.map((usage) => usage.trim())
.filter((usage): usage is CertExtendedKeyUsage => validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage));
}
return [];
};
const convertEnumsToStringArray = <T extends string>(enumArray: T[]): string[] => {
return enumArray.map((item) => item as string);
};
const combineKeyUsageFlags = (keyUsages: string[]): number => {
return keyUsages.reduce((acc: number, usage) => {
const flag = x509.KeyUsageFlags[usage as keyof typeof x509.KeyUsageFlags];
// eslint-disable-next-line no-bitwise
return typeof flag === "number" ? acc | flag : acc;
}, 0);
};
const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => {
@@ -441,11 +501,7 @@ const generateSelfSignedCertificate = async ({
...(certificateRequest.keyUsages?.length
? [
new x509.KeyUsagesExtension(
(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce(
// eslint-disable-next-line no-bitwise
(acc: number, usage) => acc | x509.KeyUsageFlags[usage],
0
),
combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []),
false
)
]
@@ -759,6 +815,37 @@ const processSelfSignedCertificate = async ({
};
};
const detectSanType = (value: string): { type: CertSubjectAlternativeNameType; value: string } => {
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(value);
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(value);
if (isIpv4 || isIpv6) {
return {
type: CertSubjectAlternativeNameType.IP_ADDRESS,
value
};
}
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(value)) {
return {
type: CertSubjectAlternativeNameType.EMAIL,
value
};
}
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(value)) {
return {
type: CertSubjectAlternativeNameType.URI,
value
};
}
return {
type: CertSubjectAlternativeNameType.DNS_NAME,
value
};
};
export const certificateV3ServiceFactory = ({
certificateDAL,
certificateBodyDAL,
@@ -773,7 +860,8 @@ export const certificateV3ServiceFactory = ({
pkiSyncDAL,
pkiSyncQueue,
kmsService,
projectDAL
projectDAL,
certificateIssuanceQueue
}: TCertificateV3ServiceFactoryDep) => {
const issueCertificateFromProfile = async ({
profileId,
@@ -1099,7 +1187,8 @@ export const certificateV3ServiceFactory = ({
actorId,
actorAuthMethod,
actorOrgId,
removeRootsFromChain
removeRootsFromChain,
certificateRequestId
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
const profile = await validateProfileAndPermissions(
profileId,
@@ -1117,7 +1206,7 @@ export const certificateV3ServiceFactory = ({
commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.altNames.map((san) => {
subjectAlternativeNames: certificateOrder.altNames?.map((san) => {
let certType: CertSubjectAlternativeNameType;
switch (san.type) {
case "dns":
@@ -1197,17 +1286,69 @@ export const certificateV3ServiceFactory = ({
};
}
if (caType === CaType.ACME) {
throw new BadRequestError({
message: "ACME certificate ordering via profiles is not yet implemented."
if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
const orderId = randomUUID();
await certificateIssuanceQueue.queueCertificateIssuance({
certificateId: orderId,
profileId: profile.id,
caId: profile.caId || "",
ttl: certificateOrder.validity?.ttl || "1y",
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
keyAlgorithm: certificateOrder.keyAlgorithm || "",
commonName: certificateOrder.commonName || "",
altNames: certificateOrder.altNames?.map((san) => san.value) || [],
keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [],
extendedKeyUsages: certificateOrder.extendedKeyUsages
? convertEnumsToStringArray(certificateOrder.extendedKeyUsages)
: [],
certificateRequestId
});
return {
orderId,
status: CertificateOrderStatus.PENDING,
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
type: san.type,
value: san.value,
status: CertificateOrderStatus.PENDING
})),
authorizations: [],
finalize: `/api/v3/pki/certificates/orders/${orderId}/finalize`,
projectId: profile.projectId,
profileName: profile.slug
};
}
throw new BadRequestError({
message: `Certificate ordering is not supported for CA type: ${caType}`
message: "Certificate ordering is not supported for the specified CA type"
});
};
// Type for internal CA renewal result
type TInternalRenewalData = {
certificate: string;
certificateChain: string;
issuingCaCertificate: string;
serialNumber: string;
newCert: TCertificates;
originalCert: TCertificates;
profile: TCertificateProfileWithConfigs | null;
};
// Type for external CA renewal result
type TExternalRenewalData = {
isExternalCA: true;
ca: TCertificateAuthorityWithAssociatedCa;
profile: TCertificateProfileWithConfigs | null;
originalCert: TCertificates;
originalSignatureAlgorithm: CertSignatureAlgorithm;
originalKeyAlgorithm: CertKeyAlgorithm;
ttl: string;
};
type TRenewalTransactionResult = TInternalRenewalData | TExternalRenewalData;
const renewCertificate = async ({
certificateId,
actor,
@@ -1215,9 +1356,10 @@ export const certificateV3ServiceFactory = ({
actorAuthMethod,
actorOrgId,
internal = false,
removeRootsFromChain
removeRootsFromChain,
certificateRequestId
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
const renewalResult = await certificateDAL.transaction(async (tx) => {
const renewalResult: TRenewalTransactionResult = await certificateDAL.transaction(async (tx) => {
const originalCert = await certificateDAL.findById(certificateId, tx);
if (!originalCert) {
throw new NotFoundError({ message: "Certificate not found" });
@@ -1229,14 +1371,30 @@ export const certificateV3ServiceFactory = ({
});
}
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm;
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm;
// Validate and cast algorithms with fallbacks
let originalSignatureAlgorithm = Object.values(CertSignatureAlgorithm).includes(
originalCert.signatureAlgorithm as CertSignatureAlgorithm
)
? (originalCert.signatureAlgorithm as CertSignatureAlgorithm)
: CertSignatureAlgorithm.RSA_SHA256;
let originalKeyAlgorithm = Object.values(CertKeyAlgorithm).includes(originalCert.keyAlgorithm as CertKeyAlgorithm)
? (originalCert.keyAlgorithm as CertKeyAlgorithm)
: CertKeyAlgorithm.RSA_2048;
// For external CA certificates without stored algorithm info, extract from certificate
if (!originalSignatureAlgorithm || !originalKeyAlgorithm) {
throw new BadRequestError({
message:
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
});
const isExternalCA = originalCert.caId && !originalCert.caId.startsWith("internal");
if (isExternalCA) {
// For external CA certificates, we can extract algorithm info from the cert or use defaults
originalSignatureAlgorithm = originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256;
originalKeyAlgorithm = originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048;
} else {
throw new BadRequestError({
message:
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
});
}
}
let profile = null;
@@ -1303,7 +1461,10 @@ export const certificateV3ServiceFactory = ({
});
}
validateCaSupport(ca, "direct certificate issuance");
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType === CaType.INTERNAL) {
validateCaSupport(ca, "direct certificate issuance");
}
}
const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId;
@@ -1334,37 +1495,7 @@ export const certificateV3ServiceFactory = ({
parseExtendedKeyUsages(originalCert.extendedKeyUsages)
),
subjectAlternativeNames: originalCert.altNames
? originalCert.altNames.split(",").map((san) => {
const trimmed = san.trim();
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed);
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed);
if (isIpv4 || isIpv6) {
return {
type: CertSubjectAlternativeNameType.IP_ADDRESS,
value: trimmed
};
}
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) {
return {
type: CertSubjectAlternativeNameType.EMAIL,
value: trimmed
};
}
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) {
return {
type: CertSubjectAlternativeNameType.URI,
value: trimmed
};
}
return {
type: CertSubjectAlternativeNameType.DNS_NAME,
value: trimmed
};
})
? originalCert.altNames.split(",").map((san) => detectSanType(san.trim()))
: [],
validity: {
ttl
@@ -1408,41 +1539,64 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" });
}
validateAlgorithmCompatibility(ca, {
algorithms: template?.algorithms
} as { algorithms?: { signature?: string[] } });
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
const caResult = await internalCaService.issueCertFromCa({
caId: ca.id,
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
commonName: originalCert.commonName || "",
altNames: originalCert.altNames || "",
ttl,
notBefore: normalizeDateForApi(notBefore),
notAfter: normalizeDateForApi(notAfter),
keyUsages: parseKeyUsages(originalCert.keyUsages),
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
signatureAlgorithm: originalSignatureAlgorithm,
keyAlgorithm: originalKeyAlgorithm,
isFromProfile: true,
actor,
actorId,
actorAuthMethod,
actorOrgId,
internal: true,
tx
});
certificate = caResult.certificate;
certificateChain = caResult.certificateChain;
issuingCaCertificate = caResult.issuingCaCertificate;
serialNumber = caResult.serialNumber;
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
if (!foundCert) {
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
// Only validate algorithm compatibility for internal CAs
if (caType === CaType.INTERNAL) {
validateAlgorithmCompatibility(ca, {
algorithms: template?.algorithms
} as { algorithms?: { signature?: string[] } });
}
if (caType === CaType.INTERNAL) {
// Internal CA renewal - existing logic
const caResult = await internalCaService.issueCertFromCa({
caId: ca.id,
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
commonName: originalCert.commonName || "",
altNames: originalCert.altNames || "",
ttl,
notBefore: normalizeDateForApi(notBefore),
notAfter: normalizeDateForApi(notAfter),
keyUsages: parseKeyUsages(originalCert.keyUsages),
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
signatureAlgorithm: originalSignatureAlgorithm,
keyAlgorithm: originalKeyAlgorithm,
isFromProfile: true,
actor,
actorId,
actorAuthMethod,
actorOrgId,
internal: true,
tx
});
certificate = caResult.certificate;
certificateChain = caResult.certificateChain;
issuingCaCertificate = caResult.issuingCaCertificate;
serialNumber = caResult.serialNumber;
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
if (!foundCert) {
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
}
newCert = foundCert;
} else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
// External CA renewal - mark for async processing outside transaction
return {
isExternalCA: true,
ca,
profile,
originalCert,
originalSignatureAlgorithm,
originalKeyAlgorithm,
ttl
};
} else {
throw new BadRequestError({
message: `CA type ${String(caType)} does not support certificate renewal`
});
}
newCert = foundCert;
} else {
// Self-signed certificate renewal
const effectiveAlgorithms = getEffectiveAlgorithms(
@@ -1522,6 +1676,48 @@ export const certificateV3ServiceFactory = ({
};
});
// Handle external CA renewals separately
if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) {
const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult;
const renewalOrderId = randomUUID();
const altNamesArray = originalCert.altNames
? originalCert.altNames.split(",").map((san: string) => san.trim())
: [];
await certificateIssuanceQueue.queueCertificateIssuance({
certificateId: renewalOrderId,
profileId: profile?.id || "",
caId: ca.id,
commonName: originalCert.commonName || "",
altNames: altNamesArray,
ttl,
signatureAlgorithm: originalSignatureAlgorithm,
keyAlgorithm: originalKeyAlgorithm,
keyUsages: convertEnumsToStringArray(parseKeyUsages(originalCert.keyUsages)),
extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)),
isRenewal: true,
originalCertificateId: certificateId,
certificateRequestId
});
return {
certificate: "", // External CA renewal is async
certificateChain: "",
issuingCaCertificate: "",
serialNumber: "",
certificateId: renewalOrderId,
projectId: originalCert.projectId,
profileName: profile?.slug || "External CA Profile",
commonName: originalCert.commonName || ""
};
}
// Type check to ensure we have internal CA renewal result
if ("isExternalCA" in renewalResult) {
throw new BadRequestError({ message: "External CA renewals should be handled asynchronously" });
}
await triggerAutoSyncForCertificate(renewalResult.newCert.id, {
certificateSyncDAL,
pkiSyncDAL,

View File

@@ -58,8 +58,10 @@ export type TOrderCertificateFromProfileDTO = {
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
template?: string;
};
removeRootsFromChain?: boolean;
certificateRequestId?: string;
} & Omit<TProjectPermission, "projectId">;
export type TCertificateFromProfileResponse = {
@@ -105,6 +107,7 @@ export type TCertificateOrderResponse = {
export type TRenewCertificateDTO = {
certificateId: string;
removeRootsFromChain?: boolean;
certificateRequestId?: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateRenewalConfigDTO = {

View File

@@ -22,14 +22,25 @@ export type TCertificateProfile = {
apiConfigId?: string;
createdAt: string;
updatedAt: string;
externalConfigs?: Record<string, unknown> | null;
certificateAuthority?: {
id: string;
projectId?: string;
status: string;
name: string;
isExternal?: boolean;
externalType?: string | null;
};
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateAuthority?: {
id: string;
projectId: string;
projectId?: string;
status: string;
name: string;
isExternal?: boolean;
externalType?: string | null;
};
certificateTemplate?: {
id: string;
@@ -72,6 +83,7 @@ export type TCreateCertificateProfileDTO = {
renewBeforeDays?: number;
};
acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
};
export type TUpdateCertificateProfileDTO = {
@@ -90,6 +102,7 @@ export type TUpdateCertificateProfileDTO = {
renewBeforeDays?: number;
};
acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
};
export type TDeleteCertificateProfileDTO = {

View File

@@ -7,4 +7,4 @@ export {
useRevokeCert,
useUpdateRenewalConfig
} from "./mutations";
export { useGetCert, useGetCertBody } from "./queries";
export { useGetCert, useGetCertBody, useGetCertificateRequest } from "./queries";

View File

@@ -13,6 +13,8 @@ import {
TRenewCertificateDTO,
TRenewCertificateResponse,
TRevokeCertDTO,
TUnifiedCertificateIssuanceDTO,
TUnifiedCertificateIssuanceResponse,
TUpdateRenewalConfigDTO
} from "./types";
@@ -185,3 +187,34 @@ export const useDownloadCertPkcs12 = () => {
}
});
};
export const useUnifiedCertificateIssuance = () => {
const queryClient = useQueryClient();
return useMutation<TUnifiedCertificateIssuanceResponse, object, TUnifiedCertificateIssuanceDTO>({
mutationFn: async (body) => {
const { projectSlug, ...requestData } = body;
const { data } = await apiRequest.post<TUnifiedCertificateIssuanceResponse>(
"/api/v3/pki/certificates",
requestData,
{
params: { projectSlug }
}
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};

View File

@@ -2,12 +2,16 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCertificate } from "./types";
import { TCertificate, TCertificateRequestDetails } from "./types";
export const certKeys = {
getCertById: (serialNumber: string) => [{ serialNumber }, "cert"],
getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"],
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"]
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"],
getCertificateRequest: (requestId: string, projectSlug: string) => [
{ requestId, projectSlug },
"certificateRequest"
]
};
export const useGetCert = (serialNumber: string) => {
@@ -55,3 +59,23 @@ export const useGetCertBundle = (serialNumber: string) => {
enabled: Boolean(serialNumber)
});
};
export const useGetCertificateRequest = (requestId: string, projectSlug: string) => {
return useQuery({
queryKey: certKeys.getCertificateRequest(requestId, projectSlug),
queryFn: async () => {
const { data } = await apiRequest.get<TCertificateRequestDetails>(
`/api/v3/pki/certificates/requests/${requestId}`,
{
params: { projectSlug }
}
);
return data;
},
enabled: Boolean(requestId) && Boolean(projectSlug),
refetchInterval: (query) => {
// Only refetch if status is pending
return query.state.data?.status === "pending" ? 5000 : false;
}
});
};

View File

@@ -64,6 +64,7 @@ export type TRenewCertificateResponse = {
serialNumber: string;
certificateId: string;
projectId: string;
certificateRequestId?: string;
};
export type TUpdateRenewalConfigDTO = {
@@ -79,3 +80,51 @@ export type TDownloadPkcs12DTO = {
password: string;
alias: string;
};
export type TUnifiedCertificateIssuanceDTO = {
projectSlug: string;
projectId: string;
profileId?: string;
caId?: string;
csr?: string;
commonName?: string;
altNames?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
notBefore?: Date;
notAfter?: Date;
keyAlgorithm?: string;
signatureAlgorithm?: string;
ttl?: string;
friendlyName?: string;
pkiCollectionId?: string;
issuerType?: string;
};
export type TUnifiedCertificateResponse = {
certificate: string;
issuingCaCertificate: string;
certificateChain: string;
privateKey?: string;
serialNumber: string;
certificateId: string;
projectId: string;
};
export type TCertificateRequestResponse = {
certificateRequestId: string;
status: "pending" | "issued" | "failed";
projectId: string;
};
export type TUnifiedCertificateIssuanceResponse =
| TUnifiedCertificateResponse
| TCertificateRequestResponse;
export type TCertificateRequestDetails = {
status: "pending" | "issued" | "failed";
certificate: TCertificate | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
};

View File

@@ -20,9 +20,9 @@ import {
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useGetCert } from "@app/hooks/api";
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useUnifiedCertificateIssuance } from "@app/hooks/api/certificates/mutations";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
@@ -103,10 +103,10 @@ type Props = {
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
serialNumber?: string;
certificate?: string;
certificateChain?: string;
privateKey?: string;
};
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
@@ -122,12 +122,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
enrollmentType: EnrollmentType.API
enrollmentType: EnrollmentType.API,
includeConfigs: true
});
const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance();
const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection));
@@ -243,7 +242,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
keyUsages,
extendedKeyUsages
}: FormData) => {
if (!currentProject?.slug) {
if (!currentProject?.slug || !currentProject?.id) {
createNotification({
text: "Project not found. Please refresh and try again.",
type: "error"
@@ -275,44 +274,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}
}
const certificateRequest: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
try {
// Prepare unified request
const request: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
projectId: currentProject.id,
ttl,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
if (constraints.shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
certificateRequest.altNames = formattedSans;
if (constraints.shouldShowSubjectSection && commonName) {
request.commonName = commonName;
}
if (signatureAlgorithm) {
request.signatureAlgorithm = signatureAlgorithm;
}
if (keyAlgorithm) {
request.keyAlgorithm = keyAlgorithm;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
request.altNames = formattedSans;
}
}
const response = await issueCertificate(request);
// Handle certificate issuance response
if ("certificate" in response) {
// Immediate certificate issuance
setCertificateDetails({
serialNumber: response.serialNumber,
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
} else {
// Certificate request - async processing
createNotification({
text: `Certificate request submitted successfully. This may take a few minutes to process. Request ID: ${response.certificateRequestId}`,
type: "success"
});
handlePopUpToggle("issueCertificate", false);
}
} catch (error) {
createNotification({
text: `Failed to request certificate: ${(error as Error)?.message || "Unknown error"}`,
type: "error"
});
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
},
[
currentProject?.slug,
createCertificate,
issueCertificate,
constraints.shouldShowSubjectSection,
constraints.shouldShowSanSection
]
@@ -321,13 +346,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const getModalTitle = () => {
if (certificateDetails) return "Certificate Created Successfully";
if (cert) return "Certificate Details";
return "Issue New Certificate";
return "Request New Certificate";
};
const getModalSubTitle = () => {
if (certificateDetails) return "Certificate has been successfully created and is ready for use";
if (cert) return "View certificate information";
return "Issue a new certificate using a certificate profile";
return "Request a new certificate using a certificate profile";
};
return (
@@ -343,10 +368,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
serialNumber={certificateDetails.serialNumber!}
certificate={certificateDetails.certificate!}
certificateChain={certificateDetails.certificateChain!}
privateKey={certificateDetails.privateKey!}
/>
)}
{cert && (
@@ -498,7 +523,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
isLoading={isSubmitting}
isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)}
>
{cert ? "Update" : "Issue Certificate"}
{cert ? "Update" : "Request Certificate"}
</Button>
<Button
colorSchema="secondary"

View File

@@ -20,12 +20,16 @@ export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) =>
const onRenewConfirm = async () => {
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
await renewCertificate({
const result = await renewCertificate({
certificateId
});
const notificationText = result.certificateRequestId
? `Certificate renewal initiated successfully. Request ID: ${result.certificateRequestId}`
: "Certificate renewed successfully";
createNotification({
text: "Certificate renewed successfully",
text: notificationText,
type: "success"
});

View File

@@ -0,0 +1,108 @@
import { useEffect } from "react";
import { faCheck, faExclamationTriangle, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useProject } from "@app/context";
import { useGetCertificateRequest } from "@app/hooks/api/certificates";
type CertificateInfo = {
id: string;
serialNumber: string;
commonName: string;
notAfter: string;
[key: string]: unknown;
};
type Props = {
requestId: string;
onCertificateIssued?: (certificate: CertificateInfo) => void;
};
export const CertificateRequestTracker = ({ requestId, onCertificateIssued }: Props) => {
const { currentProject } = useProject();
const { data: requestData, isLoading } = useGetCertificateRequest(
requestId,
currentProject?.slug || ""
);
useEffect(() => {
if (requestData?.status === "issued" && requestData.certificate && onCertificateIssued) {
onCertificateIssued(requestData.certificate);
}
}, [requestData, onCertificateIssued]);
if (isLoading) {
return (
<div className="flex items-center space-x-2">
<FontAwesomeIcon icon={faSpinner} className="animate-spin text-primary" />
<span className="text-sm text-mineshaft-400">Loading request status...</span>
</div>
);
}
const getStatusIcon = () => {
switch (requestData?.status) {
case "pending":
return <FontAwesomeIcon icon={faSpinner} className="animate-spin text-yellow-500" />;
case "issued":
return <FontAwesomeIcon icon={faCheck} className="text-green-500" />;
case "failed":
return <FontAwesomeIcon icon={faExclamationTriangle} className="text-red-500" />;
default:
return null;
}
};
const getStatusMessage = () => {
switch (requestData?.status) {
case "pending":
return "Certificate request is being processed...";
case "issued":
return "Certificate has been issued successfully!";
case "failed":
return `Certificate request failed: ${requestData.errorMessage || "Unknown error"}`;
default:
return "Unknown status";
}
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
{getStatusIcon()}
<span className="text-sm font-medium text-mineshaft-300">Request ID: {requestId}</span>
</div>
<div className="text-sm text-mineshaft-400">
Status: <span className="font-medium capitalize">{requestData?.status || "Unknown"}</span>
</div>
<div className="text-sm text-mineshaft-400">{getStatusMessage()}</div>
{requestData?.status === "issued" && requestData.certificate && (
<div className="mt-4 rounded-md border border-green-500/30 bg-green-900/20 p-3">
<div className="text-sm text-green-400">
<strong>Certificate Details:</strong>
<br />
Serial Number: {requestData.certificate.serialNumber}
<br />
Common Name: {requestData.certificate.commonName}
<br />
Valid Until: {new Date(requestData.certificate.notAfter).toLocaleDateString()}
</div>
</div>
)}
{requestData?.status === "failed" && requestData.errorMessage && (
<div className="mt-4 rounded-md border border-red-500/30 bg-red-900/20 p-3">
<div className="text-sm text-red-400">
<strong>Error Details:</strong>
<br />
{requestData.errorMessage}
</div>
</div>
)}
</div>
);
};

View File

@@ -119,7 +119,7 @@ export const CertificatesSection = () => {
onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed}
>
Issue
Request
</Button>
</div>
)}

View File

@@ -9,6 +9,7 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FilterableSelect,
FormControl,
Input,
Modal,
@@ -19,7 +20,8 @@ import {
Tooltip
} from "@app/components/v2";
import { useProject, useSubscription } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CaType } from "@app/hooks/api/ca/enums";
import { useGetAzureAdcsTemplates, useListCasByProjectId } from "@app/hooks/api/ca/queries";
import {
EnrollmentType,
IssuerType,
@@ -77,7 +79,12 @@ const createSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z.object({}).optional(),
externalConfigs: z
.object({
template: z.string().min(1, "Azure ADCS template is required")
})
.optional()
})
.refine(
(data) => {
@@ -212,7 +219,12 @@ const editSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z.object({}).optional(),
externalConfigs: z
.object({
template: z.string().optional()
})
.optional()
})
.refine(
(data) => {
@@ -339,7 +351,7 @@ export const CreateProfileModal = ({
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
const { data: allCaData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
@@ -351,9 +363,23 @@ export const CreateProfileModal = ({
const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || [];
const certificateAuthorities = (allCaData || []).map((ca) => ({
...ca,
groupType: ca.type === "internal" ? "internal" : "external"
}));
const certificateTemplates = templateData?.certificateTemplates || [];
const getGroupHeaderLabel = (groupType: "internal" | "external") => {
switch (groupType) {
case "internal":
return "Internal CAs";
case "external":
return "External CAs";
default:
return "";
}
};
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit
@@ -380,7 +406,17 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
externalConfigs: profile.externalConfigs
? {
template:
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
? profile.externalConfigs.template
: ""
}
: undefined
}
: {
slug: "",
@@ -393,15 +429,27 @@ export const CreateProfileModal = ({
autoRenew: false,
renewBeforeDays: 30
},
acmeConfig: {}
acmeConfig: {},
externalConfigs: undefined
}
});
const watchedEnrollmentType = watch("enrollmentType");
const watchedIssuerType = watch("issuerType");
const watchedCertificateAuthorityId = watch("certificateAuthorityId");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew");
// Get the selected CA to check if it's Azure ADCS
const selectedCa = certificateAuthorities.find((ca) => ca.id === watchedCertificateAuthorityId);
const isAzureAdcsCa = selectedCa?.type === CaType.AZURE_AD_CS;
// Fetch Azure ADCS templates if needed
const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({
caId: watchedCertificateAuthorityId || "",
projectId: currentProject?.id || ""
});
useEffect(() => {
if (isEdit && profile) {
reset({
@@ -427,10 +475,38 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
externalConfigs: profile.externalConfigs
? {
template:
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
? profile.externalConfigs.template
: ""
}
: undefined
});
}
}, [isEdit, profile, reset]);
}, [isEdit, profile, reset, allCaData]);
// Additional effect to reset external configs when Azure ADCS templates are loaded
useEffect(() => {
if (
isEdit &&
profile &&
isAzureAdcsCa &&
azureAdcsTemplatesData?.templates &&
profile.externalConfigs &&
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
) {
// Re-set the external configs to ensure the template value is properly set
// after the Azure ADCS templates have been loaded
setValue("externalConfigs.template", profile.externalConfigs.template);
}
}, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]);
const onFormSubmit = async (data: FormData) => {
if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
@@ -444,6 +520,18 @@ export const CreateProfileModal = ({
if (!currentProject?.id && !isEdit) return;
// Validate Azure ADCS template requirement
if (
isAzureAdcsCa &&
(!data.externalConfigs?.template || data.externalConfigs.template.trim() === "")
) {
createNotification({
text: "Azure ADCS Certificate Authority requires a template to be specified",
type: "error"
});
return;
}
if (isEdit) {
const updateData: TUpdateCertificateProfileDTO = {
profileId: profile.id,
@@ -460,6 +548,11 @@ export const CreateProfileModal = ({
updateData.acmeConfig = data.acmeConfig;
}
// Add external configs if present
if (data.externalConfigs) {
updateData.externalConfigs = data.externalConfigs;
}
await updateProfile.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
@@ -491,6 +584,11 @@ export const CreateProfileModal = ({
createData.acmeConfig = data.acmeConfig;
}
// Add external configs if present
if (data.externalConfigs) {
createData.externalConfigs = data.externalConfigs;
}
await createProfile.mutateAsync(createData);
}
@@ -587,30 +685,82 @@ export const CreateProfileModal = ({
<Controller
control={control}
name="certificateAuthorityId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
value={value || undefined}
onValueChange={onChange}
<FilterableSelect
value={certificateAuthorities.find((ca) => ca.id === value) || null}
onChange={(selectedCaValue) => {
if (Array.isArray(selectedCaValue)) {
onChange(selectedCaValue[0]?.id || "");
} else if (
selectedCaValue &&
typeof selectedCaValue === "object" &&
"id" in selectedCaValue
) {
onChange(selectedCaValue.id || "");
} else {
onChange("");
}
}}
getOptionLabel={(ca) =>
ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name
}
getOptionValue={(ca) => ca.id}
options={certificateAuthorities}
groupBy="groupType"
getGroupHeaderLabel={getGroupHeaderLabel}
placeholder="Select a certificate authority"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateAuthorities.map((ca) => (
<SelectItem key={ca.id} value={ca.id}>
{ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name}
</SelectItem>
))}
</Select>
className="w-full"
/>
</FormControl>
)}
/>
)}
{/* Azure ADCS Template Selection */}
{isAzureAdcsCa && (
<Controller
control={control}
name="externalConfigs.template"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Azure ADCS Template"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
value={
azureAdcsTemplatesData?.templates.find((template) => template.id === value) ||
null
}
onChange={(selectedTemplate) => {
if (Array.isArray(selectedTemplate)) {
onChange(selectedTemplate[0]?.id || "");
} else if (
selectedTemplate &&
typeof selectedTemplate === "object" &&
"id" in selectedTemplate
) {
onChange(selectedTemplate.id || "");
} else {
onChange("");
}
}}
getOptionLabel={(template) => template.name}
getOptionValue={(template) => template.id}
options={azureAdcsTemplatesData?.templates || []}
placeholder="Select an Azure ADCS certificate template"
className="w-full"
/>
</FormControl>
)}
/>

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { useCallback } from "react";
import {
faCheck,
@@ -49,7 +50,9 @@ export const ProfileRow = ({
}: Props) => {
const { permission } = useProjectPermission();
const { data: caData } = useGetCaById(profile.caId ?? "");
const { data: caData } = useGetCaById(
profile.certificateAuthority?.isExternal ? "" : (profile.caId ?? "")
);
const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
@@ -120,11 +123,18 @@ export const ProfileRow = ({
</Td>
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-start">
<span className="text-sm text-mineshaft-300">
{profile.issuerType === IssuerType.SELF_SIGNED
? "Self-signed"
: caData?.friendlyName || caData?.commonName || profile.caId}
</span>
<div className="flex items-center gap-2">
<span className="text-sm text-mineshaft-300">
{profile.issuerType === IssuerType.SELF_SIGNED
? "Self-signed"
: profile.certificateAuthority?.isExternal
? profile.certificateAuthority.name
: caData?.friendlyName ||
caData?.commonName ||
profile.certificateAuthority?.name ||
profile.caId}
</span>
</div>
</Td>
<Td>
<span className="text-sm text-mineshaft-300">
@@ -177,7 +187,7 @@ export const ProfileRow = ({
}}
icon={<FontAwesomeIcon icon={faPlus} className="w-3" />}
>
Issue Certificate
Request Certificate
</DropdownMenuItem>
)}
{canDeleteProfile && (