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 3947 additions and 314 deletions
+1
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/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/aws-secrets-manager.mdx:private-key:62
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61 docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246
+2
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 { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-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 { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
@@ -288,6 +289,7 @@ declare module "fastify" {
auditLogStream: TAuditLogStreamServiceFactory; auditLogStream: TAuditLogStreamServiceFactory;
certificate: TCertificateServiceFactory; certificate: TCertificateServiceFactory;
certificateV3: TCertificateV3ServiceFactory; certificateV3: TCertificateV3ServiceFactory;
certificateRequest: TCertificateRequestServiceFactory;
certificateTemplate: TCertificateTemplateServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory;
certificateTemplateV2: TCertificateTemplateV2ServiceFactory; certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
certificateProfile: TCertificateProfileServiceFactory; certificateProfile: TCertificateProfileServiceFactory;
+10
View File
@@ -573,6 +573,11 @@ import {
TWorkflowIntegrationsInsert, TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate TWorkflowIntegrationsUpdate
} from "@app/db/schemas"; } from "@app/db/schemas";
import {
TCertificateRequests,
TCertificateRequestsInsert,
TCertificateRequestsUpdate
} from "@app/db/schemas/certificate-requests";
import { import {
TAccessApprovalPoliciesEnvironments, TAccessApprovalPoliciesEnvironments,
TAccessApprovalPoliciesEnvironmentsInsert, TAccessApprovalPoliciesEnvironmentsInsert,
@@ -714,6 +719,11 @@ declare module "knex/types/tables" {
TExternalCertificateAuthoritiesUpdate TExternalCertificateAuthoritiesUpdate
>; >;
[TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>; [TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
[TableName.CertificateRequests]: KnexOriginal.CompositeTableType<
TCertificateRequests,
TCertificateRequestsInsert,
TCertificateRequestsUpdate
>;
[TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType<
TCertificateTemplates, TCertificateTemplates,
TCertificateTemplatesInsert, TCertificateTemplatesInsert,
@@ -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);
}
@@ -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");
});
}
}
@@ -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>>;
+1
View File
@@ -16,6 +16,7 @@ export * from "./certificate-authority-certs";
export * from "./certificate-authority-crl"; export * from "./certificate-authority-crl";
export * from "./certificate-authority-secret"; export * from "./certificate-authority-secret";
export * from "./certificate-bodies"; export * from "./certificate-bodies";
export * from "./certificate-requests";
export * from "./certificate-secrets"; export * from "./certificate-secrets";
export * from "./certificate-syncs"; export * from "./certificate-syncs";
export * from "./certificate-template-est-configs"; export * from "./certificate-template-est-configs";
+1
View File
@@ -21,6 +21,7 @@ export enum TableName {
CertificateAuthorityCrl = "certificate_authority_crl", CertificateAuthorityCrl = "certificate_authority_crl",
Certificate = "certificates", Certificate = "certificates",
CertificateBody = "certificate_bodies", CertificateBody = "certificate_bodies",
CertificateRequests = "certificate_requests",
CertificateSecret = "certificate_secrets", CertificateSecret = "certificate_secrets",
CertificateTemplate = "certificate_templates", CertificateTemplate = "certificate_templates",
PkiCertificateTemplateV2 = "pki_certificate_templates_v2", PkiCertificateTemplateV2 = "pki_certificate_templates_v2",
@@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
acmeConfigId: z.string().uuid().nullable().optional(), 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>; export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
@@ -94,7 +94,7 @@ import {
type TPkiAcmeServiceFactoryDep = { type TPkiAcmeServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">; projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">; certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">; externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">; certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
@@ -776,6 +776,7 @@ export const pkiAcmeServiceFactory = ({
const cert = await orderCertificate( const cert = await orderCertificate(
{ {
caId: certificateAuthority!.id, caId: certificateAuthority!.id,
profileId,
commonName: certificateRequest.commonName!, commonName: certificateRequest.commonName!,
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
csr: Buffer.from(csrPem), csr: Buffer.from(csrPem),
+17
View File
@@ -63,6 +63,7 @@ export enum QueueName {
DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretRevocation = "dynamic-secret-revocation",
CaCrlRotation = "ca-crl-rotation", CaCrlRotation = "ca-crl-rotation",
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
CertificateIssuance = "certificate-issuance",
SecretReplication = "secret-replication", SecretReplication = "secret-replication",
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
PkiSync = "pki-sync", PkiSync = "pki-sync",
@@ -125,6 +126,7 @@ export enum QueueJobs {
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
SecretScanningV2SendNotification = "secret-scanning-v2-notification", SecretScanningV2SendNotification = "secret-scanning-v2-notification",
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile",
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
TelemetryAggregatedEvents = "telemetry-aggregated-events", TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders", DailyReminders = "daily-reminders",
@@ -343,6 +345,21 @@ export type TQueueJobTypes = {
caType: CaType; 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]: { [QueueName.DailyReminders]: {
name: QueueJobs.DailyReminders; name: QueueJobs.DailyReminders;
payload: undefined; payload: undefined;
+34 -2
View File
@@ -173,6 +173,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author
import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue";
import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; 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 { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-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"; 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 { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; 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 { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-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 certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db);
const certificateSecretDAL = certificateSecretDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db);
const certificateRequestDAL = certificateRequestDALFactory(db);
const certificateSyncDAL = certificateSyncDALFactory(db); const certificateSyncDAL = certificateSyncDALFactory(db);
const pkiAlertDAL = pkiAlertDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db);
@@ -1187,7 +1191,7 @@ export const registerRoutes = async (
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthorityCertDAL, externalCertificateAuthorityDAL,
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
@@ -2208,6 +2212,31 @@ export const registerRoutes = async (
pkiSyncQueue 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({ const certificateV3Service = certificateV3ServiceFactory({
certificateDAL, certificateDAL,
certificateSecretDAL, certificateSecretDAL,
@@ -2222,7 +2251,8 @@ export const registerRoutes = async (
pkiSyncQueue, pkiSyncQueue,
kmsService, kmsService,
projectDAL, projectDAL,
certificateBodyDAL certificateBodyDAL,
certificateIssuanceQueue
}); });
const certificateV3Queue = certificateV3QueueServiceFactory({ const certificateV3Queue = certificateV3QueueServiceFactory({
@@ -2448,6 +2478,7 @@ export const registerRoutes = async (
await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiSubscriberQueue.startDailyAutoRenewalJob();
await pkiAlertV2Queue.init(); await pkiAlertV2Queue.init();
await certificateV3Queue.init(); await certificateV3Queue.init();
await certificateIssuanceQueue.initializeCertificateIssuanceQueue();
await microsoftTeamsService.start(); await microsoftTeamsService.start();
await dynamicSecretQueueService.init(); await dynamicSecretQueueService.init();
await eventBusService.init(); await eventBusService.init();
@@ -2513,6 +2544,7 @@ export const registerRoutes = async (
auditLogStream: auditLogStreamService, auditLogStream: auditLogStreamService,
certificate: certificateService, certificate: certificateService,
certificateV3: certificateV3Service, certificateV3: certificateV3Service,
certificateRequest: certificateRequestService,
certificateEstV3: certificateEstV3Service, certificateEstV3: certificateEstV3Service,
sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService, sshCertificateTemplate: sshCertificateTemplateService,
@@ -46,7 +46,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional() acmeConfig: z.object({}).optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -149,7 +150,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
), ),
response: { response: {
200: z.object({ 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: { response: {
200: z.object({ 200: z.object({
certificateProfiles: PkiCertificateProfilesSchema.extend({ 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 metrics: z
.object({ .object({
profileId: z.string(), profileId: z.string(),
@@ -234,7 +246,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
id: z.string(), id: z.string(),
directoryUrl: z.string() directoryUrl: z.string()
}) })
.optional() .optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
}).array(), }).array(),
totalCount: z.number() totalCount: z.number()
}) })
@@ -280,12 +293,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({ certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
}).extend({
certificateAuthority: z certificateAuthority: z
.object({ .object({
id: z.string(), id: z.string(),
projectId: z.string(), projectId: z.string().optional(),
status: z.string(), status: z.string(),
name: z.string() name: z.string(),
isExternal: z.boolean().optional(),
externalType: z.string().nullable().optional()
}) })
.optional(), .optional(),
certificateTemplate: z certificateTemplate: z
@@ -310,7 +327,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(), autoRenew: z.boolean(),
renewBeforeDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional() .optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
}) })
}) })
} }
@@ -358,7 +376,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}), }),
response: { response: {
200: z.object({ 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), autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional(),
externalConfigs: z.record(z.unknown()).nullable().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -434,7 +455,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
), ),
response: { response: {
200: z.object({ 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: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: z.record(z.unknown()).nullable().optional()
})
}) })
} }
}, },
@@ -1,9 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { TCertificateRequests } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs"; import { ApiDocsTags } from "@app/lib/api-docs";
import { NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { import {
@@ -12,6 +14,7 @@ import {
CertKeyAlgorithm, CertKeyAlgorithm,
CertSignatureAlgorithm CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
@@ -21,6 +24,7 @@ import {
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
import { booleanSchema } from "../sanitizedSchemas"; import { booleanSchema } from "../sanitizedSchemas";
@@ -57,7 +61,398 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
return true; return true;
}; };
const validateCertificateRequestFlow = (data: {
csr?: string;
subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>;
commonName?: string;
altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>;
}) => {
const hasCSR = !!data.csr;
const hasSANs = !!data.subjectAlternativeNames?.length;
const hasStandardFields = !!(data.commonName || data.altNames?.length);
const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields);
return flowCount === 1;
};
export const registerCertificatesRouter = async (server: FastifyZodProvider) => { export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
projectId: z.string().uuid(),
commonName: validateTemplateRegexField.optional(),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
altNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "SAN value cannot be empty")
})
)
.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm),
csr: z
.string()
.trim()
.min(1, "CSR cannot be empty")
.max(4096, "CSR cannot exceed 4096 characters")
.optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z
.string()
.trim()
.min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters")
})
)
.optional(),
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
removeRootsFromChain: booleanSchema.default(false).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
})
.refine(validateCertificateRequestFlow, {
message:
"Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)"
}),
response: {
200: z.union([
z.object({
certificate: z.string().trim(),
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(),
certificateId: z.string(),
certificateRequestId: z.string()
}),
z.object({
certificateRequestId: z.string(),
status: z.string(),
orderId: z.string().optional(),
subjectAlternativeNames: z
.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
)
.optional(),
authorizations: z
.array(
z.object({
identifier: z.object({
type: z.nativeEnum(ACMESANType),
value: z.string()
}),
status: z.nativeEnum(CertificateOrderStatus),
expires: z.string().optional(),
challenges: z.array(
z.object({
type: z.string(),
status: z.nativeEnum(CertificateOrderStatus),
url: z.string(),
token: z.string()
})
)
})
)
.optional(),
finalize: z.string().optional()
})
])
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { csr, subjectAlternativeNames, ...requestBody } = req.body;
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.projectId,
profileId: requestBody.profileId,
csr,
commonName: requestBody.commonName,
altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
keyAlgorithm: requestBody.keyAlgorithm,
signatureAlgorithm: requestBody.signatureAlgorithm,
metadata: JSON.stringify({
ttl: requestBody.ttl,
removeRootsFromChain: requestBody.removeRootsFromChain,
subjectAlternativeNames
})
});
try {
if (csr) {
const extractedCsrData = extractCertificateRequestFromCSR(csr);
const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
csr,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
enrollmentType: EnrollmentType.API,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
profileName: data.profileName,
commonName: extractedCsrData.commonName || ""
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
}
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId
});
let useOrderFlow = false;
if (profile?.caId) {
const ca = await server.services.certificateAuthority.getCaById({
caId: profile.caId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL;
useOrderFlow = caType !== CaType.INTERNAL;
}
if (subjectAlternativeNames?.length || useOrderFlow) {
let acmeAltNames = subjectAlternativeNames;
if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) {
acmeAltNames = requestBody.altNames.map((alt) => ({
type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME
? ACMESANType.DNS
: ACMESANType.IP) as ACMESANType,
value: alt.value
}));
}
const certificateOrderObject = {
altNames: acmeAltNames || [],
validity: { ttl: requestBody.ttl },
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const data = await server.services.certificateV3.orderCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateOrder: certificateOrderObject,
removeRootsFromChain: requestBody.removeRootsFromChain,
certificateRequestId: certificateRequest.id
});
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.PENDING,
errorMessage: undefined
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
orderId: data.orderId,
profileName: data.profileName
}
}
});
return {
certificateRequestId: certificateRequest.id,
...data
};
}
const certificateRequestForService: CertificateRequestForService = {
commonName: requestBody.commonName,
keyUsages: requestBody.keyUsages,
extendedKeyUsages: requestBody.extendedKeyUsages,
altNames: requestBody.altNames,
validity: { ttl: requestBody.ttl },
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
signatureAlgorithm: requestBody.signatureAlgorithm,
keyAlgorithm: requestBody.keyAlgorithm
};
const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService);
const data = await server.services.certificateV3.issueCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: requestBody.profileId,
certificateRequest: mappedCertificateRequest,
removeRootsFromChain: requestBody.removeRootsFromChain
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
commonName: requestBody.commonName || "",
profileName: data.profileName
}
}
});
return {
...data,
certificateRequestId: certificateRequest.id
};
} catch (error) {
await server.services.certificateRequest.updateCertificateRequestStatus({
certificateRequestId: certificateRequest.id,
status: CertificateRequestStatus.FAILED,
errorMessage: error instanceof Error ? error.message : "Unknown error"
});
throw error;
}
}
});
server.route({
method: "GET",
url: "/certificate-requests/:certificateRequestId/certificate",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
params: z.object({
certificateRequestId: z.string().uuid()
}),
query: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
status: z.nativeEnum(CertificateRequestStatus),
certificate: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable(),
errorMessage: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.certificateRequest.getCertificateFromRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: (req.query as { projectId: string }).projectId,
certificateRequestId: req.params.certificateRequestId
});
if (data.certificate && data.serialNumber) {
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: (req.query as { projectId: string }).projectId,
event: {
type: EventType.GET_CERT,
metadata: {
certId: req.params.certificateRequestId,
cn: "",
serialNumber: data.serialNumber || ""
}
}
});
}
return data;
}
});
server.route({ server.route({
method: "POST", method: "POST",
url: "/issue-certificate", url: "/issue-certificate",
@@ -65,8 +460,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -106,7 +503,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
@@ -138,6 +536,28 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
commonName: req.body.commonName,
altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","),
keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages,
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
keyAlgorithm: req.body.keyAlgorithm,
signatureAlgorithm: req.body.signatureAlgorithm
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -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 rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true, // Hide deprecated endpoint from docs
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -191,14 +616,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
issuingCaCertificate: z.string().trim(), issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({ const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -215,6 +639,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
csr: req.body.csr,
commonName: certificateRequestData.commonName,
altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
keyUsages: certificateRequestData.keyUsages,
extendedKeyUsages: certificateRequestData.extendedKeyUsages,
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
keyAlgorithm: certificateRequestData.keyAlgorithm,
signatureAlgorithm: certificateRequestData.signatureAlgorithm
});
await server.services.certificateRequest.attachCertificateToRequest({
certificateRequestId: certificateRequest.id,
certificateId: data.certificateId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -224,12 +673,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
profileName: data.profileName, profileName: data.profileName,
commonName: certificateRequest.commonName || "" commonName: certificateRequestData.commonName || ""
} }
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -240,13 +692,14 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true, // Hide deprecated endpoint from docs
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
subjectAlternativeNames: z subjectAlternativeNames: z.array(
.array(
z.object({ z.object({
type: z.nativeEnum(ACMESANType), type: z.nativeEnum(ACMESANType),
value: z value: z
@@ -255,8 +708,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
.min(1, "SAN value cannot be empty") .min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters") .max(255, "SAN value must be less than 255 characters")
}) })
) ),
.min(1, "At least one subject alternative name must be provided"),
ttl: z ttl: z
.string() .string()
.trim() .trim()
@@ -308,19 +760,14 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}) })
), ),
finalize: z.string(), finalize: z.string(),
certificate: z.string().optional() certificate: z.string().optional(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const data = await server.services.certificateV3.orderCertificateFromProfile({ const certificateOrderObject = {
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, altNames: req.body.subjectAlternativeNames,
validity: { validity: {
ttl: req.body.ttl ttl: req.body.ttl
@@ -332,10 +779,35 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
signatureAlgorithm: req.body.signatureAlgorithm, signatureAlgorithm: req.body.signatureAlgorithm,
keyAlgorithm: req.body.keyAlgorithm 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: certificateOrderObject,
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: data.projectId,
profileId: req.body.profileId,
commonName: req.body.commonName,
altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages,
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
signatureAlgorithm: req.body.signatureAlgorithm,
keyAlgorithm: req.body.keyAlgorithm
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -349,7 +821,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -377,21 +852,59 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
let certificateRequest: TCertificateRequests | undefined;
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" });
}
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({ const data = await server.services.certificateV3.renewCertificate({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
certificateId: req.params.certificateId, certificateId: req.params.certificateId,
removeRootsFromChain: req.body?.removeRootsFromChain 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({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -406,7 +919,20 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
} }
}); });
return data; 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;
}
} }
}); });
@@ -1,6 +1,7 @@
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import acme, { CsrBuffer } from "acme-client"; import acme, { CsrBuffer } from "acme-client";
import { Knex } from "knex"; import { Knex } from "knex";
import RE2 from "re2";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
@@ -23,6 +24,7 @@ import {
CertKeyUsage, CertKeyUsage,
CertStatus CertStatus
} from "@app/services/certificate/certificate-types"; } 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 { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
@@ -45,6 +47,60 @@ import {
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; 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 = { type TAcmeCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">; appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
@@ -53,7 +109,7 @@ type TAcmeCertificateAuthorityFnsDeps = {
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>; >;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">; externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">; certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick< kmsService: Pick<
@@ -64,13 +120,14 @@ type TAcmeCertificateAuthorityFnsDeps = {
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
}; };
type TOrderCertificateDeps = { type TOrderCertificateDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">; externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">; certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick< kmsService: Pick<
@@ -78,6 +135,7 @@ type TOrderCertificateDeps = {
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
>; >;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
}; };
type DBConfigurationColumn = { type DBConfigurationColumn = {
@@ -91,7 +149,7 @@ type DBConfigurationColumn = {
export const castDbEntryToAcmeCertificateAuthority = ( export const castDbEntryToAcmeCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>> ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAcmeCertificateAuthority & { credentials: unknown } => { ): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => {
if (!ca.externalCa?.id) { if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed ACME certificate authority" }); throw new BadRequestError({ message: "Malformed ACME certificate authority" });
} }
@@ -123,15 +181,22 @@ export const castDbEntryToAcmeCertificateAuthority = (
export const orderCertificate = async ( export const orderCertificate = async (
{ {
caId, caId,
profileId,
subscriberId, subscriberId,
commonName, commonName,
altNames, altNames,
csr, csr,
csrPrivateKey, csrPrivateKey,
keyUsages, keyUsages,
extendedKeyUsages extendedKeyUsages,
ttl,
signatureAlgorithm,
keyAlgorithm,
isRenewal,
originalCertificateId
}: { }: {
caId: string; caId: string;
profileId?: string;
subscriberId?: string; subscriberId?: string;
commonName: string; commonName: string;
altNames?: string[]; altNames?: string[];
@@ -139,6 +204,11 @@ export const orderCertificate = async (
csrPrivateKey?: string; csrPrivateKey?: string;
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[];
ttl?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
isRenewal?: boolean;
originalCertificateId?: string;
}, },
deps: TOrderCertificateDeps, deps: TOrderCertificateDeps,
tx?: Knex tx?: Knex
@@ -151,7 +221,8 @@ export const orderCertificate = async (
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
kmsService, kmsService,
projectDAL projectDAL,
certificateProfileDAL
} = deps; } = deps;
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
@@ -181,7 +252,7 @@ export const orderCertificate = async (
let accountKey: Buffer | undefined; let accountKey: Buffer | undefined;
if (acmeCa.credentials) { if (acmeCa.credentials) {
const decryptedCredentials = await kmsDecryptor({ const decryptedCredentials = await kmsDecryptor({
cipherTextBlob: acmeCa.credentials as Buffer cipherTextBlob: acmeCa.credentials
}); });
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
@@ -322,6 +393,7 @@ export const orderCertificate = async (
{ {
caId: ca.id, caId: ca.id,
pkiSubscriberId: subscriberId, pkiSubscriberId: subscriberId,
profileId,
status: CertStatus.ACTIVE, status: CertStatus.ACTIVE,
friendlyName: commonName, friendlyName: commonName,
commonName, commonName,
@@ -331,11 +403,18 @@ export const orderCertificate = async (
notAfter: certObj.notAfter, notAfter: certObj.notAfter,
keyUsages, keyUsages,
extendedKeyUsages, extendedKeyUsages,
projectId: ca.projectId keyAlgorithm,
signatureAlgorithm,
projectId: ca.projectId,
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
}, },
innerTx innerTx
); );
if (isRenewal && originalCertificateId) {
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx);
}
await certificateBodyDAL.create( await certificateBodyDAL.create(
{ {
certId: cert.id, 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; return cert;
}); });
}; };
@@ -371,7 +470,8 @@ export const AcmeCertificateAuthorityFns = ({
projectDAL, projectDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}: TAcmeCertificateAuthorityFnsDeps) => { }: TAcmeCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({ const createCertificateAuthority = async ({
name, name,
@@ -616,10 +716,71 @@ export const AcmeCertificateAuthorityFns = ({
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); 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 { return {
createCertificateAuthority, createCertificateAuthority,
updateCertificateAuthority, updateCertificateAuthority,
listCertificateAuthorities, listCertificateAuthorities,
orderSubscriberCertificate orderSubscriberCertificate,
orderCertificateFromProfile
}; };
}; };
@@ -21,8 +21,10 @@ import {
CertExtendedKeyUsage, CertExtendedKeyUsage,
CertKeyAlgorithm, CertKeyAlgorithm,
CertKeyUsage, CertKeyUsage,
CertStatus CertStatus,
TAltNameType
} from "@app/services/certificate/certificate-types"; } 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 { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
@@ -42,6 +44,60 @@ import {
TUpdateAzureAdCsCertificateAuthorityDTO TUpdateAzureAdCsCertificateAuthorityDTO
} from "./azure-ad-cs-certificate-authority-types"; } 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 = { type TAzureAdCsCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">; appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
@@ -50,7 +106,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
>; >;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">; externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">; certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick< kmsService: Pick<
@@ -61,6 +117,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
}; };
type AzureCertificateRequest = { type AzureCertificateRequest = {
@@ -190,7 +247,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie
export const castDbEntryToAzureAdCsCertificateAuthority = ( export const castDbEntryToAzureAdCsCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>> ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAzureAdCsCertificateAuthority & { credentials: unknown } => { ): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => {
if (!ca.externalCa?.id) { if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" }); throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
} }
@@ -591,7 +648,8 @@ export const AzureAdCsCertificateAuthorityFns = ({
projectDAL, projectDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}: TAzureAdCsCertificateAuthorityFnsDeps) => { }: TAzureAdCsCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({ const createCertificateAuthority = async ({
name, 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 getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca || ca.projectId !== projectId) { if (!ca || ca.projectId !== projectId) {
@@ -1182,6 +1618,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
updateCertificateAuthority, updateCertificateAuthority,
listCertificateAuthorities, listCertificateAuthorities,
orderSubscriberCertificate, orderSubscriberCertificate,
orderCertificateFromProfile,
getTemplates getTemplates
}; };
}; };
@@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({
azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") 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({ export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
type: z.literal(CaType.AZURE_AD_CS), type: z.literal(CaType.AZURE_AD_CS),
configuration: AzureAdCsCertificateAuthorityConfigurationSchema configuration: AzureAdCsCertificateAuthorityConfigurationSchema
@@ -11,6 +11,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
@@ -62,7 +63,7 @@ type TCertificateAuthorityServiceFactoryDep = {
internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory; internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">; certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick< kmsService: Pick<
@@ -72,6 +73,7 @@ type TCertificateAuthorityServiceFactoryDep = {
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
}; };
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>; export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
@@ -90,7 +92,8 @@ export const certificateAuthorityServiceFactory = ({
kmsService, kmsService,
pkiSubscriberDAL, pkiSubscriberDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}: TCertificateAuthorityServiceFactoryDep) => { }: TCertificateAuthorityServiceFactoryDep) => {
const acmeFns = AcmeCertificateAuthorityFns({ const acmeFns = AcmeCertificateAuthorityFns({
appConnectionDAL, appConnectionDAL,
@@ -104,7 +107,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL, pkiSubscriberDAL,
projectDAL, projectDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}); });
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
@@ -119,7 +123,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL, pkiSubscriberDAL,
projectDAL, projectDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}); });
const createCertificateAuthority = async ( 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 { return {
createCertificateAuthority, createCertificateAuthority,
findCertificateAuthorityByNameAndProjectId, findCertificateAuthorityByNameAndProjectId,
listCertificateAuthoritiesByProjectId, listCertificateAuthoritiesByProjectId,
updateCertificateAuthority, updateCertificateAuthority,
deleteCertificateAuthority, deleteCertificateAuthority,
getAzureAdcsTemplates getAzureAdcsTemplates,
getCaById
}; };
}; };
@@ -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
};
};
@@ -22,10 +22,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => { const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => {
try { try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ const dataToInsert = {
TCertificateProfile ...data,
]; externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null
return certificateProfile; };
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) { } catch (error) {
throw new DatabaseError({ error, name: "Create certificate profile" }); 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> => { const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise<TCertificateProfile> => {
try { 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 }) .where({ id })
.update(data) .update(dataToUpdate)
.returning("*")) as [TCertificateProfile]; .returning("*");
return certificateProfile;
return {
...updatedProfile,
externalConfigs: updatedProfile.externalConfigs
? (JSON.parse(updatedProfile.externalConfigs) as Record<string, unknown>)
: null
} as TCertificateProfile;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" }); 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> => { const findById = async (id: string, tx?: Knex): Promise<TCertificateProfile | undefined> => {
try { try {
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first();
| TCertificateProfile
| undefined; if (!certificateProfile) return undefined;
return certificateProfile;
return {
...certificateProfile,
externalConfigs: certificateProfile.externalConfigs
? (JSON.parse(certificateProfile.externalConfigs) as Record<string, unknown>)
: null
} as TCertificateProfile;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by id" }); throw new DatabaseError({ error, name: "Find certificate profile by id" });
} }
@@ -203,6 +232,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
estConfigId: result.estConfigId, estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId, apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId, acmeConfigId: result.acmeConfigId,
externalConfigs: result.externalConfigs
? (JSON.parse(result.externalConfigs) as Record<string, unknown>)
: null,
createdAt: result.createdAt, createdAt: result.createdAt,
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
estConfig, estConfig,
@@ -277,6 +309,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
const query = baseQuery const query = baseQuery
.leftJoin(
TableName.CertificateAuthority,
`${TableName.PkiCertificateProfile}.caId`,
`${TableName.CertificateAuthority}.id`
)
.leftJoin(
TableName.ExternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.caId`
)
.leftJoin( .leftJoin(
TableName.PkiEstEnrollmentConfig, TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`, `${TableName.PkiCertificateProfile}.estConfigId`,
@@ -294,6 +336,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
) )
.select(selectAllTableCols(TableName.PkiCertificateProfile)) .select(selectAllTableCols(TableName.PkiCertificateProfile))
.select( .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("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db db
.ref("disableBootstrapCaValidation") .ref("disableBootstrapCaValidation")
@@ -337,6 +384,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
: undefined; : 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 = { const baseProfile = {
id: result.id, id: result.id,
projectId: result.projectId, projectId: result.projectId,
@@ -349,11 +406,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
estConfigId: result.estConfigId, estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId, apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId, acmeConfigId: result.acmeConfigId,
externalConfigs: result.externalConfigs
? (JSON.parse(result.externalConfigs as string) as Record<string, unknown>)
: null,
createdAt: result.createdAt, createdAt: result.createdAt,
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
estConfig, estConfig,
apiConfig, apiConfig,
acmeConfig acmeConfig,
certificateAuthority
}; };
return baseProfile as TCertificateProfileWithConfigs; return baseProfile as TCertificateProfileWithConfigs;
@@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { ActorType, AuthMethod } from "../auth/auth-type"; import { ActorType, AuthMethod } from "../auth/auth-type";
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-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 { 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 type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
@@ -100,6 +100,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfigId: "api-config-123", apiConfigId: "api-config-123",
estConfigId: null, estConfigId: null,
externalConfigs: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}; };
@@ -229,17 +230,10 @@ describe("CertificateProfileService", () => {
delete: vi.fn() delete: vi.fn()
} as unknown as TCertificateAuthorityDALFactory; } as unknown as TCertificateAuthorityDALFactory;
const mockCertificateAuthorityCertDAL = { const mockExternalCertificateAuthorityDAL = {
create: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
updateById: vi.fn(), findOne: vi.fn()
deleteById: vi.fn(), } as unknown as Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateAuthorityCertDALFactory;
beforeEach(() => { beforeEach(() => {
vi.spyOn(ForbiddenError, "from").mockReturnValue({ vi.spyOn(ForbiddenError, "from").mockReturnValue({
@@ -261,7 +255,7 @@ describe("CertificateProfileService", () => {
certificateBodyDAL: mockCertificateBodyDAL, certificateBodyDAL: mockCertificateBodyDAL,
certificateSecretDAL: mockCertificateSecretDAL, certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL,
permissionService: mockPermissionService, permissionService: mockPermissionService,
licenseService: mockLicenseService, licenseService: mockLicenseService,
kmsService: mockKmsService, kmsService: mockKmsService,
@@ -19,8 +19,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; 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 { 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 { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-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 ( const generateAndEncryptAcmeEabSecret = async (
projectId: string, projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">, kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
@@ -180,7 +230,7 @@ type TCertificateProfileServiceFactoryDep = {
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">; externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">; kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
@@ -190,10 +240,22 @@ type TCertificateProfileServiceFactoryDep = {
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>; export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => { 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 { return {
...dalResult, ...dalResult,
enrollmentType: dalResult.enrollmentType as EnrollmentType, enrollmentType: dalResult.enrollmentType as EnrollmentType,
issuerType: dalResult.issuerType as IssuerType issuerType: dalResult.issuerType as IssuerType,
externalConfigs: parsedExternalConfigs
} as TCertificateProfile; } as TCertificateProfile;
}; };
@@ -205,6 +267,8 @@ export const certificateProfileServiceFactory = ({
acmeEnrollmentConfigDAL, acmeEnrollmentConfigDAL,
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
@@ -272,6 +336,14 @@ export const certificateProfileServiceFactory = ({
validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null);
// Validate external configs
await validateExternalConfigs(
data.externalConfigs,
data.caId ?? null,
certificateAuthorityDAL,
externalCertificateAuthorityDAL
);
// Validate enrollment configuration requirements // Validate enrollment configuration requirements
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
@@ -340,7 +412,8 @@ export const certificateProfileServiceFactory = ({
projectId, projectId,
estConfigId, estConfigId,
apiConfigId, apiConfigId,
acmeConfigId acmeConfigId,
externalConfigs: data.externalConfigs
}, },
tx tx
); );
@@ -414,6 +487,16 @@ export const certificateProfileServiceFactory = ({
validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); 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 = const updatedData =
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; 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 { return {
...profile, ...profile,
enrollmentType: profile.enrollmentType as EnrollmentType enrollmentType: profile.enrollmentType as EnrollmentType,
externalConfigs: parsedExternalConfigs
}; };
}; };
@@ -15,19 +15,28 @@ export enum IssuerType {
SELF_SIGNED = "self-signed" SELF_SIGNED = "self-signed"
} }
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType"> & { export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType" | "externalConfigs"> & {
enrollmentType: EnrollmentType; enrollmentType: EnrollmentType;
issuerType: IssuerType; issuerType: IssuerType;
externalConfigs?: Record<string, unknown> | null;
}; };
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType" | "issuerType"> & { export type TCertificateProfileInsert = Omit<
TPkiCertificateProfilesInsert,
"enrollmentType" | "issuerType" | "externalConfigs"
> & {
enrollmentType: EnrollmentType; enrollmentType: EnrollmentType;
issuerType: IssuerType; issuerType: IssuerType;
externalConfigs?: Record<string, unknown> | null;
}; };
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType" | "issuerType"> & { export type TCertificateProfileUpdate = Omit<
TPkiCertificateProfilesUpdate,
"enrollmentType" | "issuerType" | "externalConfigs"
> & {
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
issuerType?: IssuerType; issuerType?: IssuerType;
externalConfigs?: Record<string, unknown> | null;
estConfig?: { estConfig?: {
disableBootstrapCaValidation?: boolean; disableBootstrapCaValidation?: boolean;
passphrase?: string; passphrase?: string;
@@ -47,9 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
}; };
certificateAuthority?: { certificateAuthority?: {
id: string; id: string;
projectId: string; projectId?: string;
status: string; status: string;
name: string; name: string;
isExternal?: boolean;
externalType?: string;
}; };
certificateTemplate?: { certificateTemplate?: {
id: string; id: string;
@@ -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
};
};
@@ -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);
});
});
});
@@ -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
};
};
@@ -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;
};
@@ -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;
};
@@ -42,7 +42,7 @@ describe("CertificateV3Service", () => {
const mockCertificateDAL: Pick< const mockCertificateDAL: Pick<
TCertificateDALFactory, TCertificateDALFactory,
"findOne" | "findById" | "updateById" | "transaction" | "create" "findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
> = { > = {
findOne: vi.fn(), findOne: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
@@ -57,7 +57,8 @@ describe("CertificateV3Service", () => {
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => { transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {}; const mockTx = {};
return callback(mockTx); return callback(mockTx);
}) }),
find: vi.fn().mockResolvedValue([])
}; };
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create"> = { const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create"> = {
@@ -65,12 +66,24 @@ describe("CertificateV3Service", () => {
create: vi.fn() create: vi.fn()
}; };
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = { const mockCertificateAuthorityDAL: Pick<
findByIdWithAssociatedCa: vi.fn() 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"> = { const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById"> = {
findByIdWithConfigs: vi.fn() findByIdWithConfigs: vi.fn(),
findById: vi.fn()
}; };
const mockCertificateTemplateV2Service: Pick< const mockCertificateTemplateV2Service: Pick<
@@ -168,7 +181,11 @@ describe("CertificateV3Service", () => {
kmsService: { kmsService: {
generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"), generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"),
encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))), 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: { projectDAL: {
findOne: vi.fn().mockResolvedValue({ id: "project-123" }), findOne: vi.fn().mockResolvedValue({ id: "project-123" }),
@@ -178,7 +195,10 @@ describe("CertificateV3Service", () => {
const mockTx = {}; const mockTx = {};
return callback(mockTx); return callback(mockTx);
}) })
} as any } as any,
certificateIssuanceQueue: {
queueCertificateIssuance: vi.fn().mockResolvedValue(undefined)
}
}); });
}); });
@@ -40,7 +40,11 @@ import {
} from "@app/services/certificate-authority/certificate-authority-fns"; } from "@app/services/certificate-authority/certificate-authority-fns";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; 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 { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -85,11 +89,17 @@ import {
} from "./certificate-v3-types"; } from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = { type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction" | "create">; certificateDAL: Pick<
TCertificateDALFactory,
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">; certificateAuthorityDAL: Pick<
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">; TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById"
>;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById">;
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">; acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
certificateTemplateV2Service: Pick< certificateTemplateV2Service: Pick<
TCertificateTemplateV2ServiceFactory, TCertificateTemplateV2ServiceFactory,
@@ -103,8 +113,15 @@ type TCertificateV3ServiceFactoryDep = {
>; >;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">; kmsService: Pick<
TKmsServiceFactory,
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
>;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
certificateIssuanceQueue: Pick<
import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory,
"queueCertificateIssuance"
>;
}; };
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>; export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
@@ -294,14 +311,57 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
if (!keyUsages) return []; 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[] => { const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
if (!extendedKeyUsages) return []; 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 => { const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => {
@@ -441,11 +501,7 @@ const generateSelfSignedCertificate = async ({
...(certificateRequest.keyUsages?.length ...(certificateRequest.keyUsages?.length
? [ ? [
new x509.KeyUsagesExtension( new x509.KeyUsagesExtension(
(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce( combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []),
// eslint-disable-next-line no-bitwise
(acc: number, usage) => acc | x509.KeyUsageFlags[usage],
0
),
false 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 = ({ export const certificateV3ServiceFactory = ({
certificateDAL, certificateDAL,
certificateBodyDAL, certificateBodyDAL,
@@ -773,7 +860,8 @@ export const certificateV3ServiceFactory = ({
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue, pkiSyncQueue,
kmsService, kmsService,
projectDAL projectDAL,
certificateIssuanceQueue
}: TCertificateV3ServiceFactoryDep) => { }: TCertificateV3ServiceFactoryDep) => {
const issueCertificateFromProfile = async ({ const issueCertificateFromProfile = async ({
profileId, profileId,
@@ -1099,7 +1187,8 @@ export const certificateV3ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
removeRootsFromChain removeRootsFromChain,
certificateRequestId
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => { }: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
const profile = await validateProfileAndPermissions( const profile = await validateProfileAndPermissions(
profileId, profileId,
@@ -1117,7 +1206,7 @@ export const certificateV3ServiceFactory = ({
commonName: certificateOrder.commonName, commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages, keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.altNames.map((san) => { subjectAlternativeNames: certificateOrder.altNames?.map((san) => {
let certType: CertSubjectAlternativeNameType; let certType: CertSubjectAlternativeNameType;
switch (san.type) { switch (san.type) {
case "dns": case "dns":
@@ -1197,17 +1286,69 @@ export const certificateV3ServiceFactory = ({
}; };
} }
if (caType === CaType.ACME) { if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
throw new BadRequestError({ const orderId = randomUUID();
message: "ACME certificate ordering via profiles is not yet implemented."
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({ 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 ({ const renewCertificate = async ({
certificateId, certificateId,
actor, actor,
@@ -1215,9 +1356,10 @@ export const certificateV3ServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
internal = false, internal = false,
removeRootsFromChain removeRootsFromChain,
certificateRequestId
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => { }: 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); const originalCert = await certificateDAL.findById(certificateId, tx);
if (!originalCert) { if (!originalCert) {
throw new NotFoundError({ message: "Certificate not found" }); throw new NotFoundError({ message: "Certificate not found" });
@@ -1229,15 +1371,31 @@ export const certificateV3ServiceFactory = ({
}); });
} }
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; // Validate and cast algorithms with fallbacks
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; 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) { if (!originalSignatureAlgorithm || !originalKeyAlgorithm) {
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({ throw new BadRequestError({
message: message:
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
}); });
} }
}
let profile = null; let profile = null;
if (originalCert.profileId) { if (originalCert.profileId) {
@@ -1303,8 +1461,11 @@ export const certificateV3ServiceFactory = ({
}); });
} }
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType === CaType.INTERNAL) {
validateCaSupport(ca, "direct certificate issuance"); validateCaSupport(ca, "direct certificate issuance");
} }
}
const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId;
const template = templateId const template = templateId
@@ -1334,37 +1495,7 @@ export const certificateV3ServiceFactory = ({
parseExtendedKeyUsages(originalCert.extendedKeyUsages) parseExtendedKeyUsages(originalCert.extendedKeyUsages)
), ),
subjectAlternativeNames: originalCert.altNames subjectAlternativeNames: originalCert.altNames
? originalCert.altNames.split(",").map((san) => { ? originalCert.altNames.split(",").map((san) => detectSanType(san.trim()))
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
};
})
: [], : [],
validity: { validity: {
ttl ttl
@@ -1408,10 +1539,17 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" });
} }
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
// Only validate algorithm compatibility for internal CAs
if (caType === CaType.INTERNAL) {
validateAlgorithmCompatibility(ca, { validateAlgorithmCompatibility(ca, {
algorithms: template?.algorithms algorithms: template?.algorithms
} as { algorithms?: { signature?: string[] } }); } as { algorithms?: { signature?: string[] } });
}
if (caType === CaType.INTERNAL) {
// Internal CA renewal - existing logic
const caResult = await internalCaService.issueCertFromCa({ const caResult = await internalCaService.issueCertFromCa({
caId: ca.id, caId: ca.id,
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
@@ -1443,6 +1581,22 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
} }
newCert = foundCert; 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`
});
}
} else { } else {
// Self-signed certificate renewal // Self-signed certificate renewal
const effectiveAlgorithms = getEffectiveAlgorithms( 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, { await triggerAutoSyncForCertificate(renewalResult.newCert.id, {
certificateSyncDAL, certificateSyncDAL,
pkiSyncDAL, pkiSyncDAL,
@@ -58,8 +58,10 @@ export type TOrderCertificateFromProfileDTO = {
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
template?: string;
}; };
removeRootsFromChain?: boolean; removeRootsFromChain?: boolean;
certificateRequestId?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TCertificateFromProfileResponse = { export type TCertificateFromProfileResponse = {
@@ -105,6 +107,7 @@ export type TCertificateOrderResponse = {
export type TRenewCertificateDTO = { export type TRenewCertificateDTO = {
certificateId: string; certificateId: string;
removeRootsFromChain?: boolean; removeRootsFromChain?: boolean;
certificateRequestId?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TUpdateRenewalConfigDTO = { export type TUpdateRenewalConfigDTO = {
@@ -22,14 +22,25 @@ export type TCertificateProfile = {
apiConfigId?: string; apiConfigId?: string;
createdAt: string; createdAt: string;
updatedAt: 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 & { export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateAuthority?: { certificateAuthority?: {
id: string; id: string;
projectId: string; projectId?: string;
status: string; status: string;
name: string; name: string;
isExternal?: boolean;
externalType?: string | null;
}; };
certificateTemplate?: { certificateTemplate?: {
id: string; id: string;
@@ -72,6 +83,7 @@ export type TCreateCertificateProfileDTO = {
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
acmeConfig?: unknown; acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
}; };
export type TUpdateCertificateProfileDTO = { export type TUpdateCertificateProfileDTO = {
@@ -90,6 +102,7 @@ export type TUpdateCertificateProfileDTO = {
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
acmeConfig?: unknown; acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
}; };
export type TDeleteCertificateProfileDTO = { export type TDeleteCertificateProfileDTO = {
@@ -7,4 +7,4 @@ export {
useRevokeCert, useRevokeCert,
useUpdateRenewalConfig useUpdateRenewalConfig
} from "./mutations"; } from "./mutations";
export { useGetCert, useGetCertBody } from "./queries"; export { useGetCert, useGetCertBody, useGetCertificateRequest } from "./queries";
@@ -13,6 +13,8 @@ import {
TRenewCertificateDTO, TRenewCertificateDTO,
TRenewCertificateResponse, TRenewCertificateResponse,
TRevokeCertDTO, TRevokeCertDTO,
TUnifiedCertificateIssuanceDTO,
TUnifiedCertificateIssuanceResponse,
TUpdateRenewalConfigDTO TUpdateRenewalConfigDTO
} from "./types"; } 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)
});
}
});
};
@@ -2,12 +2,16 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { TCertificate } from "./types"; import { TCertificate, TCertificateRequestDetails } from "./types";
export const certKeys = { export const certKeys = {
getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], getCertById: (serialNumber: string) => [{ serialNumber }, "cert"],
getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"], 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) => { export const useGetCert = (serialNumber: string) => {
@@ -55,3 +59,23 @@ export const useGetCertBundle = (serialNumber: string) => {
enabled: Boolean(serialNumber) 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;
}
});
};
@@ -64,6 +64,7 @@ export type TRenewCertificateResponse = {
serialNumber: string; serialNumber: string;
certificateId: string; certificateId: string;
projectId: string; projectId: string;
certificateRequestId?: string;
}; };
export type TUpdateRenewalConfigDTO = { export type TUpdateRenewalConfigDTO = {
@@ -79,3 +80,51 @@ export type TDownloadPkcs12DTO = {
password: string; password: string;
alias: 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;
};
@@ -20,9 +20,9 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useGetCert } from "@app/hooks/api"; import { useGetCert } from "@app/hooks/api";
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; 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 { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
@@ -103,10 +103,10 @@ type Props = {
}; };
type TCertificateDetails = { type TCertificateDetails = {
serialNumber: string; serialNumber?: string;
certificate: string; certificate?: string;
certificateChain: string; certificateChain?: string;
privateKey: string; privateKey?: string;
}; };
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
@@ -122,12 +122,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({ const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
enrollmentType: EnrollmentType.API enrollmentType: EnrollmentType.API,
includeConfigs: true
}); });
const { mutateAsync: createCertificate } = useCreateCertificateV3({ const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance();
projectId: currentProject?.id
});
const formResolver = useMemo(() => { const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection)); return zodResolver(createSchema(shouldShowSubjectSection));
@@ -243,7 +242,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
keyUsages, keyUsages,
extendedKeyUsages extendedKeyUsages
}: FormData) => { }: FormData) => {
if (!currentProject?.slug) { if (!currentProject?.slug || !currentProject?.id) {
createNotification({ createNotification({
text: "Project not found. Please refresh and try again.", text: "Project not found. Please refresh and try again.",
type: "error" type: "error"
@@ -275,44 +274,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
} }
} }
const certificateRequest: any = { try {
// Prepare unified request
const request: any = {
profileId: formProfileId, profileId: formProfileId,
projectSlug: currentProject.slug, projectSlug: currentProject.slug,
projectId: currentProject.id,
ttl, ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[], keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
}; };
if (constraints.shouldShowSubjectSection && commonName) { if (constraints.shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName; request.commonName = commonName;
} }
if (signatureAlgorithm) {
request.signatureAlgorithm = signatureAlgorithm;
}
if (keyAlgorithm) {
request.keyAlgorithm = keyAlgorithm;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames); const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) { if (formattedSans && formattedSans.length > 0) {
certificateRequest.altNames = formattedSans; request.altNames = formattedSans;
} }
} }
const { serialNumber, certificate, certificateChain, privateKey } = const response = await issueCertificate(request);
await createCertificate(certificateRequest);
// Handle certificate issuance response
if ("certificate" in response) {
// Immediate certificate issuance
setCertificateDetails({ setCertificateDetails({
serialNumber, serialNumber: response.serialNumber,
certificate, certificate: response.certificate,
certificateChain, certificateChain: response.certificateChain,
privateKey privateKey: response.privateKey
}); });
createNotification({ createNotification({
text: "Successfully created certificate", text: "Successfully created certificate",
type: "success" 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"
});
}
}, },
[ [
currentProject?.slug, currentProject?.slug,
createCertificate, issueCertificate,
constraints.shouldShowSubjectSection, constraints.shouldShowSubjectSection,
constraints.shouldShowSanSection constraints.shouldShowSanSection
] ]
@@ -321,13 +346,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const getModalTitle = () => { const getModalTitle = () => {
if (certificateDetails) return "Certificate Created Successfully"; if (certificateDetails) return "Certificate Created Successfully";
if (cert) return "Certificate Details"; if (cert) return "Certificate Details";
return "Issue New Certificate"; return "Request New Certificate";
}; };
const getModalSubTitle = () => { const getModalSubTitle = () => {
if (certificateDetails) return "Certificate has been successfully created and is ready for use"; if (certificateDetails) return "Certificate has been successfully created and is ready for use";
if (cert) return "View certificate information"; 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 ( return (
@@ -343,10 +368,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}> <ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && ( {certificateDetails && (
<CertificateContent <CertificateContent
serialNumber={certificateDetails.serialNumber} serialNumber={certificateDetails.serialNumber!}
certificate={certificateDetails.certificate} certificate={certificateDetails.certificate!}
certificateChain={certificateDetails.certificateChain} certificateChain={certificateDetails.certificateChain!}
privateKey={certificateDetails.privateKey} privateKey={certificateDetails.privateKey!}
/> />
)} )}
{cert && ( {cert && (
@@ -498,7 +523,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
isLoading={isSubmitting} isLoading={isSubmitting}
isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)} isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)}
> >
{cert ? "Update" : "Issue Certificate"} {cert ? "Update" : "Request Certificate"}
</Button> </Button>
<Button <Button
colorSchema="secondary" colorSchema="secondary"
@@ -20,12 +20,16 @@ export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) =>
const onRenewConfirm = async () => { const onRenewConfirm = async () => {
const { certificateId } = popUp.renewCertificate.data as { certificateId: string }; const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
await renewCertificate({ const result = await renewCertificate({
certificateId certificateId
}); });
const notificationText = result.certificateRequestId
? `Certificate renewal initiated successfully. Request ID: ${result.certificateRequestId}`
: "Certificate renewed successfully";
createNotification({ createNotification({
text: "Certificate renewed successfully", text: notificationText,
type: "success" type: "success"
}); });
@@ -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>
);
};
@@ -119,7 +119,7 @@ export const CertificatesSection = () => {
onClick={() => handlePopUpOpen("issueCertificate")} onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Issue Request
</Button> </Button>
</div> </div>
)} )}
@@ -9,6 +9,7 @@ import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
Checkbox, Checkbox,
FilterableSelect,
FormControl, FormControl,
Input, Input,
Modal, Modal,
@@ -19,7 +20,8 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject, useSubscription } from "@app/context"; 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 { import {
EnrollmentType, EnrollmentType,
IssuerType, IssuerType,
@@ -77,7 +79,12 @@ const createSchema = z
renewBeforeDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.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( .refine(
(data) => { (data) => {
@@ -212,7 +219,12 @@ const editSchema = z
renewBeforeDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional() acmeConfig: z.object({}).optional(),
externalConfigs: z
.object({
template: z.string().optional()
})
.optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -339,7 +351,7 @@ export const CreateProfileModal = ({
const { currentProject } = useProject(); const { currentProject } = useProject();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { data: caData } = useListCasByProjectId(currentProject?.id || ""); const { data: allCaData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({ const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
limit: 100, limit: 100,
@@ -351,9 +363,23 @@ export const CreateProfileModal = ({
const isEdit = mode === "edit" && profile; 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 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>({ const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema), resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit defaultValues: isEdit
@@ -380,7 +406,17 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined, : 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: "", slug: "",
@@ -393,15 +429,27 @@ export const CreateProfileModal = ({
autoRenew: false, autoRenew: false,
renewBeforeDays: 30 renewBeforeDays: 30
}, },
acmeConfig: {} acmeConfig: {},
externalConfigs: undefined
} }
}); });
const watchedEnrollmentType = watch("enrollmentType"); const watchedEnrollmentType = watch("enrollmentType");
const watchedIssuerType = watch("issuerType"); const watchedIssuerType = watch("issuerType");
const watchedCertificateAuthorityId = watch("certificateAuthorityId");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew"); 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(() => { useEffect(() => {
if (isEdit && profile) { if (isEdit && profile) {
reset({ reset({
@@ -427,10 +475,38 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined, : 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) => { const onFormSubmit = async (data: FormData) => {
if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
@@ -444,6 +520,18 @@ export const CreateProfileModal = ({
if (!currentProject?.id && !isEdit) return; 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) { if (isEdit) {
const updateData: TUpdateCertificateProfileDTO = { const updateData: TUpdateCertificateProfileDTO = {
profileId: profile.id, profileId: profile.id,
@@ -460,6 +548,11 @@ export const CreateProfileModal = ({
updateData.acmeConfig = data.acmeConfig; updateData.acmeConfig = data.acmeConfig;
} }
// Add external configs if present
if (data.externalConfigs) {
updateData.externalConfigs = data.externalConfigs;
}
await updateProfile.mutateAsync(updateData); await updateProfile.mutateAsync(updateData);
} else { } else {
if (!currentProject?.id) { if (!currentProject?.id) {
@@ -491,6 +584,11 @@ export const CreateProfileModal = ({
createData.acmeConfig = data.acmeConfig; createData.acmeConfig = data.acmeConfig;
} }
// Add external configs if present
if (data.externalConfigs) {
createData.externalConfigs = data.externalConfigs;
}
await createProfile.mutateAsync(createData); await createProfile.mutateAsync(createData);
} }
@@ -587,30 +685,82 @@ export const CreateProfileModal = ({
<Controller <Controller
control={control} control={control}
name="certificateAuthorityId" name="certificateAuthorityId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl <FormControl
label="Issuing CA" label="Issuing CA"
isRequired isRequired
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Select <FilterableSelect
{...field} value={certificateAuthorities.find((ca) => ca.id === value) || null}
value={value || undefined} onChange={(selectedCaValue) => {
onValueChange={onChange} if (Array.isArray(selectedCaValue)) {
placeholder="Select a certificate authority" onChange(selectedCaValue[0]?.id || "");
className="w-full" } else if (
position="popper" selectedCaValue &&
isDisabled={Boolean(isEdit)} typeof selectedCaValue === "object" &&
> "id" in selectedCaValue
{certificateAuthorities.map((ca) => ( ) {
<SelectItem key={ca.id} value={ca.id}> onChange(selectedCaValue.id || "");
{ca.type === "internal" && ca.configuration.friendlyName } else {
onChange("");
}
}}
getOptionLabel={(ca) =>
ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName ? ca.configuration.friendlyName
: ca.name} : ca.name
</SelectItem> }
))} getOptionValue={(ca) => ca.id}
</Select> options={certificateAuthorities}
groupBy="groupType"
getGroupHeaderLabel={getGroupHeaderLabel}
placeholder="Select a certificate authority"
isDisabled={Boolean(isEdit)}
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> </FormControl>
)} )}
/> />
@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { useCallback } from "react"; import { useCallback } from "react";
import { import {
faCheck, faCheck,
@@ -49,7 +50,9 @@ export const ProfileRow = ({
}: Props) => { }: Props) => {
const { permission } = useProjectPermission(); 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); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
@@ -120,11 +123,18 @@ export const ProfileRow = ({
</Td> </Td>
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td> <Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-start"> <Td className="text-start">
<div className="flex items-center gap-2">
<span className="text-sm text-mineshaft-300"> <span className="text-sm text-mineshaft-300">
{profile.issuerType === IssuerType.SELF_SIGNED {profile.issuerType === IssuerType.SELF_SIGNED
? "Self-signed" ? "Self-signed"
: caData?.friendlyName || caData?.commonName || profile.caId} : profile.certificateAuthority?.isExternal
? profile.certificateAuthority.name
: caData?.friendlyName ||
caData?.commonName ||
profile.certificateAuthority?.name ||
profile.caId}
</span> </span>
</div>
</Td> </Td>
<Td> <Td>
<span className="text-sm text-mineshaft-300"> <span className="text-sm text-mineshaft-300">
@@ -177,7 +187,7 @@ export const ProfileRow = ({
}} }}
icon={<FontAwesomeIcon icon={faPlus} className="w-3" />} icon={<FontAwesomeIcon icon={faPlus} className="w-3" />}
> >
Issue Certificate Request Certificate
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{canDeleteProfile && ( {canDeleteProfile && (