Merge pull request #4972 from Infisical/feat/PKI-55

feature(pki): add external CA support on PKI
This commit is contained in:
carlosmonastyrski
2025-12-02 17:28:33 -03:00
committed by GitHub
47 changed files with 4158 additions and 707 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>;
@@ -388,6 +388,9 @@ export enum EventType {
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle", GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
CREATE_CERTIFICATE_REQUEST = "create-certificate-request",
GET_CERTIFICATE_REQUEST = "get-certificate-request",
GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
@@ -2846,7 +2849,6 @@ interface OrderCertificateFromProfile {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; type: EventType.ORDER_CERTIFICATE_FROM_PROFILE;
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
orderId: string;
profileName: string; profileName: string;
}; };
} }
@@ -4196,6 +4198,31 @@ interface DisableCertificateRenewalConfigEvent {
}; };
} }
interface CreateCertificateRequestEvent {
type: EventType.CREATE_CERTIFICATE_REQUEST;
metadata: {
certificateRequestId: string;
profileId?: string;
caId?: string;
commonName?: string;
};
}
interface GetCertificateRequestEvent {
type: EventType.GET_CERTIFICATE_REQUEST;
metadata: {
certificateRequestId: string;
};
}
interface GetCertificateFromRequestEvent {
type: EventType.GET_CERTIFICATE_FROM_REQUEST;
metadata: {
certificateRequestId: string;
certificateId?: string;
};
}
export type Event = export type Event =
| CreateSubOrganizationEvent | CreateSubOrganizationEvent
| UpdateSubOrganizationEvent | UpdateSubOrganizationEvent
@@ -4575,6 +4602,9 @@ export type Event =
| PamResourceDeleteEvent | PamResourceDeleteEvent
| UpdateCertificateRenewalConfigEvent | UpdateCertificateRenewalConfigEvent
| DisableCertificateRenewalConfigEvent | DisableCertificateRenewalConfigEvent
| CreateCertificateRequestEvent
| GetCertificateRequestEvent
| GetCertificateFromRequestEvent
| AutomatedRenewCertificate | AutomatedRenewCertificate
| AutomatedRenewCertificateFailed | AutomatedRenewCertificateFailed
| UserLoginEvent | UserLoginEvent
@@ -95,7 +95,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">;
+17
View File
@@ -64,6 +64,7 @@ export enum QueueName {
DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email", DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email",
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",
@@ -128,6 +129,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",
@@ -355,6 +357,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;
+35 -2
View File
@@ -174,6 +174,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";
@@ -181,6 +182,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";
@@ -1093,6 +1096,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);
@@ -1188,7 +1192,7 @@ export const registerRoutes = async (
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthorityCertDAL, externalCertificateAuthorityDAL,
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
@@ -2216,6 +2220,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,
@@ -2230,7 +2259,9 @@ export const registerRoutes = async (
pkiSyncQueue, pkiSyncQueue,
kmsService, kmsService,
projectDAL, projectDAL,
certificateBodyDAL certificateBodyDAL,
certificateIssuanceQueue,
certificateRequestService
}); });
const certificateV3Queue = certificateV3QueueServiceFactory({ const certificateV3Queue = certificateV3QueueServiceFactory({
@@ -2463,6 +2494,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();
@@ -2528,6 +2560,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,
@@ -8,6 +8,7 @@ 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 { CertStatus } from "@app/services/certificate/certificate-types"; import { CertStatus } from "@app/services/certificate/certificate-types";
import { ExternalConfigUnionSchema } from "@app/services/certificate-profile/certificate-profile-external-config-schemas";
import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types";
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
@@ -46,7 +47,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: ExternalConfigUnionSchema
}) })
.refine( .refine(
(data) => { (data) => {
@@ -149,7 +151,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
), ),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: ExternalConfigUnionSchema
})
}) })
} }
}, },
@@ -204,6 +208,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 +247,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
id: z.string(), id: z.string(),
directoryUrl: z.string() directoryUrl: z.string()
}) })
.optional() .optional(),
externalConfigs: ExternalConfigUnionSchema
}).array(), }).array(),
totalCount: z.number() totalCount: z.number()
}) })
@@ -280,12 +294,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({ certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: ExternalConfigUnionSchema
}).extend({
certificateAuthority: z certificateAuthority: z
.object({ .object({
id: z.string(), id: z.string(),
projectId: z.string(), projectId: z.string(),
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 +328,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(), autoRenew: z.boolean(),
renewBeforeDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional() .optional(),
externalConfigs: ExternalConfigUnionSchema
}) })
}) })
} }
@@ -358,7 +377,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: ExternalConfigUnionSchema
})
}) })
} }
}, },
@@ -412,7 +433,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: ExternalConfigUnionSchema
}) })
.refine( .refine(
(data) => { (data) => {
@@ -434,7 +456,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
), ),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: ExternalConfigUnionSchema
})
}) })
} }
}, },
@@ -479,7 +503,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema certificateProfile: PkiCertificateProfilesSchema.extend({
externalConfigs: ExternalConfigUnionSchema
})
}) })
} }
}, },
@@ -5,18 +5,14 @@ import { z } from "zod";
import { CertificatesSchema } from "@app/db/schemas"; import { CertificatesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs"; import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs";
import { NotFoundError } from "@app/lib/errors";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { addNoCacheHeaders } from "@app/server/lib/caching"; import { addNoCacheHeaders } from "@app/server/lib/caching";
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 { CertKeyAlgorithm, CertSignatureAlgorithm, CrlReason } from "@app/services/certificate/certificate-types";
ACMESANType, import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm,
CrlReason
} from "@app/services/certificate/certificate-types";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
@@ -26,10 +22,23 @@ 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 { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types";
import { booleanSchema } from "../sanitizedSchemas"; import { booleanSchema } from "../sanitizedSchemas";
type CertificateServiceResponse = TCertificateFromProfileResponse | Omit<TCertificateFromProfileResponse, "privateKey">;
const extractCertificateData = (data: CertificateServiceResponse) => ({
certificate: data.certificate,
issuingCaCertificate: data.issuingCaCertificate,
certificateChain: data.certificateChain,
privateKey: "privateKey" in data ? data.privateKey : undefined,
serialNumber: data.serialNumber,
certificateId: data.certificateId
});
interface CertificateRequestForService { interface CertificateRequestForService {
commonName?: string; commonName?: string;
keyUsages?: CertKeyUsageType[]; keyUsages?: CertKeyUsageType[];
@@ -47,13 +56,32 @@ interface CertificateRequestForService {
keyAlgorithm?: string; keyAlgorithm?: string;
} }
const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { const validateTtlAndDateFields = (data: {
attributes?: { notBefore?: string; notAfter?: string; ttl?: string };
notBefore?: string;
notAfter?: string;
ttl?: string;
}) => {
if (data.attributes) {
const hasDateFields = data.attributes.notBefore || data.attributes.notAfter;
const hasTtl = data.attributes.ttl;
return !(hasDateFields && hasTtl);
}
const hasDateFields = data.notBefore || data.notAfter; const hasDateFields = data.notBefore || data.notAfter;
const hasTtl = data.ttl; const hasTtl = data.ttl;
return !(hasDateFields && hasTtl); return !(hasDateFields && hasTtl);
}; };
const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { const validateDateOrder = (data: {
attributes?: { notBefore?: string; notAfter?: string };
notBefore?: string;
notAfter?: string;
}) => {
if (data.attributes?.notBefore && data.attributes?.notAfter) {
const notBefore = new Date(data.attributes.notBefore);
const notAfter = new Date(data.attributes.notAfter);
return notBefore < notAfter;
}
if (data.notBefore && data.notAfter) { if (data.notBefore && data.notAfter) {
const notBefore = new Date(data.notBefore); const notBefore = new Date(data.notBefore);
const notAfter = new Date(data.notAfter); const notAfter = new Date(data.notAfter);
@@ -65,13 +93,279 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
export const registerCertificateRouter = async (server: FastifyZodProvider) => { export const registerCertificateRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "POST", method: "POST",
url: "/issue-certificate", url: "/",
config: { config: {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
csr: z
.string()
.trim()
.min(1, "CSR cannot be empty")
.max(4096, "CSR cannot exceed 4096 characters")
.optional(),
attributes: z
.object({
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).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).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()
})
.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"
}),
response: {
200: z.object({
certificate: 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()
})
.nullable(),
certificateRequestId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { csr, attributes, ...requestBody } = req.body;
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 (useOrderFlow) {
const certificateOrderObject = {
altNames: attributes?.altNames || [],
validity: { ttl: attributes?.ttl || "" },
commonName: attributes?.commonName,
keyUsages: attributes?.keyUsages,
extendedKeyUsages: attributes?.extendedKeyUsages,
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
signatureAlgorithm: attributes?.signatureAlgorithm,
keyAlgorithm: attributes?.keyAlgorithm,
csr
};
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
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
profileName: data.profileName
}
}
});
return {
certificate: null,
certificateRequestId: data.certificateRequestId
};
}
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: attributes?.ttl || "" },
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
enrollmentType: EnrollmentType.API,
removeRootsFromChain: requestBody.removeRootsFromChain
});
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 {
certificate: extractCertificateData(data),
certificateRequestId: data.certificateRequestId
};
}
const certificateRequestForService: CertificateRequestForService = {
commonName: attributes?.commonName,
keyUsages: attributes?.keyUsages,
extendedKeyUsages: attributes?.extendedKeyUsages,
altNames: attributes?.altNames,
validity: { ttl: attributes?.ttl || "" },
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
signatureAlgorithm: attributes?.signatureAlgorithm,
keyAlgorithm: attributes?.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.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: requestBody.profileId,
certificateId: data.certificateId,
commonName: attributes?.commonName || "",
profileName: data.profileName
}
}
});
return {
certificate: extractCertificateData(data),
certificateRequestId: data.certificateRequestId
};
}
});
server.route({
method: "GET",
url: "/certificate-requests/:requestId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
params: z.object({
requestId: z.string().uuid()
}),
query: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
status: z.nativeEnum(CertificateRequestStatus),
certificate: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable(),
errorMessage: z.string().nullable(),
createdAt: z.date(),
updatedAt: z.date()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.certificateRequest.getCertificateFromRequest({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: (req.query as { projectId: string }).projectId,
certificateRequestId: req.params.requestId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: (req.query as { projectId: string }).projectId,
event: {
type: EventType.GET_CERTIFICATE_REQUEST,
metadata: {
certificateRequestId: req.params.requestId
}
}
});
return data;
}
});
server.route({
method: "POST",
url: "/issue-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -111,7 +405,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
@@ -168,8 +463,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
@@ -196,14 +493,13 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
issuingCaCertificate: z.string().trim(), issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({ const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -220,6 +516,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: data.projectId, projectId: data.projectId,
@@ -229,7 +527,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
profileName: data.profileName, profileName: data.profileName,
commonName: certificateRequest.commonName || "" commonName: certificateRequestData.commonName || ""
} }
} }
}); });
@@ -245,23 +543,23 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
rateLimit: writeLimit rateLimit: writeLimit
}, },
schema: { schema: {
hide: false, hide: true,
deprecated: true,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
description: "This endpoint will be removed in a future version.",
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
subjectAlternativeNames: z subjectAlternativeNames: z.array(
.array(
z.object({ z.object({
type: z.nativeEnum(ACMESANType), type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z value: z
.string() .string()
.trim() .trim()
.min(1, "SAN value cannot be empty") .min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters") .max(255, "SAN value must be less than 255 characters")
}) })
) ),
.min(1, "At least one subject alternative name must be provided"),
ttl: z ttl: z
.string() .string()
.trim() .trim()
@@ -285,47 +583,14 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
orderId: z.string(), certificate: z.string().optional(),
status: z.nativeEnum(CertificateOrderStatus), certificateRequestId: z.string()
subjectAlternativeNames: z.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
),
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()
})
)
})
),
finalize: z.string(),
certificate: z.string().optional()
}) })
} }
}, },
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
@@ -337,7 +602,15 @@ export const registerCertificateRouter = 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
}); });
@@ -348,7 +621,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
orderId: data.orderId,
profileName: data.profileName profileName: data.profileName
} }
} }
@@ -382,12 +654,24 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
certificateChain: z.string().trim(), certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(), privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(), serialNumber: z.string().trim(),
certificateId: z.string() certificateId: z.string(),
certificateRequestId: z.string()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const originalCertificate = await server.services.certificate.getCert({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.id
});
if (!originalCertificate) {
throw new NotFoundError({ message: "Original certificate not found" });
}
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,
@@ -2,16 +2,12 @@ import { z } from "zod";
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 { 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 { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types";
ACMESANType,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
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 +17,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";
@@ -65,8 +62,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: "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 +105,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 +138,29 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
removeRootsFromChain: req.body.removeRootsFromChain removeRootsFromChain: req.body.removeRootsFromChain
}); });
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
status: CertificateRequestStatus.ISSUED,
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 +175,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -163,8 +189,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: "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 +219,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 +242,32 @@ 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,
status: CertificateRequestStatus.ISSUED,
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 +277,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,23 +296,23 @@ 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: "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(CertSubjectAlternativeNameType),
value: z value: z
.string() .string()
.trim() .trim()
.min(1, "SAN value cannot be empty") .min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters") .max(255, "SAN value must be less than 255 characters")
}) })
) ),
.min(1, "At least one subject alternative name must be provided"),
ttl: z ttl: z
.string() .string()
.trim() .trim()
@@ -280,47 +336,14 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}), }),
response: { response: {
200: z.object({ 200: z.object({
orderId: z.string(), certificate: z.string().optional(),
status: z.nativeEnum(CertificateOrderStatus), certificateRequestId: z.string()
subjectAlternativeNames: z.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
),
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()
})
)
})
),
finalize: z.string(),
certificate: z.string().optional()
}) })
} }
}, },
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 +355,36 @@ 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({
status: CertificateRequestStatus.PENDING,
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,
@@ -343,13 +392,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
orderId: data.orderId,
profileName: data.profileName profileName: data.profileName
} }
} }
}); });
return data; return {
...data,
certificateRequestId: certificateRequest.id
};
} }
}); });
@@ -377,12 +428,24 @@ 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) => {
const originalCertificate = await server.services.certificate.getCert({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.params.certificateId
});
if (!originalCertificate) {
throw new NotFoundError({ message: "Original certificate not found" });
}
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,
@@ -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";
@@ -24,6 +25,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";
@@ -47,6 +49,60 @@ import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-prov
import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy"; import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy";
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">;
@@ -55,7 +111,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<
@@ -66,13 +122,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<
@@ -80,6 +137,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 = {
@@ -93,7 +151,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" });
} }
@@ -141,15 +199,22 @@ const getAcmeChallengeRecord = (
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[];
@@ -157,6 +222,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
@@ -169,7 +239,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);
@@ -199,7 +270,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(
@@ -364,6 +435,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,
@@ -373,11 +445,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,
@@ -397,6 +476,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;
}); });
}; };
@@ -413,7 +512,8 @@ export const AcmeCertificateAuthorityFns = ({
projectDAL, projectDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}: TAcmeCertificateAuthorityFnsDeps) => { }: TAcmeCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({ const createCertificateAuthority = async ({
name, name,
@@ -668,10 +768,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,
@@ -1024,6 +1082,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) {
@@ -1163,6 +1599,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";
@@ -63,7 +64,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<
@@ -73,6 +74,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>;
@@ -91,7 +93,8 @@ export const certificateAuthorityServiceFactory = ({
kmsService, kmsService,
pkiSubscriberDAL, pkiSubscriberDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}: TCertificateAuthorityServiceFactoryDep) => { }: TCertificateAuthorityServiceFactoryDep) => {
const acmeFns = AcmeCertificateAuthorityFns({ const acmeFns = AcmeCertificateAuthorityFns({
appConnectionDAL, appConnectionDAL,
@@ -105,7 +108,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL, pkiSubscriberDAL,
projectDAL, projectDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}); });
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
@@ -120,7 +124,8 @@ export const certificateAuthorityServiceFactory = ({
pkiSubscriberDAL, pkiSubscriberDAL,
projectDAL, projectDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue,
certificateProfileDAL
}); });
const createCertificateAuthority = async ( const createCertificateAuthority = async (
@@ -677,6 +682,41 @@ 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,
findCertificateAuthorityById, findCertificateAuthorityById,
@@ -685,6 +725,7 @@ export const certificateAuthorityServiceFactory = ({
updateCertificateAuthority, updateCertificateAuthority,
deleteCertificateAuthority, deleteCertificateAuthority,
getAzureAdcsTemplates, getAzureAdcsTemplates,
getCaById,
deprecatedUpdateCertificateAuthority, deprecatedUpdateCertificateAuthority,
deprecatedDeleteCertificateAuthority deprecatedDeleteCertificateAuthority
}; };
@@ -0,0 +1,377 @@
import acme from "acme-client";
import { crypto } from "@app/lib/crypto/cryptography";
import { 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, 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 { CertKeyAlgorithm } from "../certificate-common/certificate-constants";
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;
csr?: 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 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,
csr
}: TIssueCertificateFromProfileJobData) => {
const jobData: TIssueCertificateFromProfileJobData = {
certificateId,
profileId,
caId,
commonName,
altNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages,
isRenewal,
originalCertificateId,
certificateRequestId,
csr
};
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,
csr
} = 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) {
let certificateCsr: string;
let skLeaf: string = "";
if (csr) {
certificateCsr = csr;
} else {
const keyAlg = keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm);
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]);
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const [, generatedCsr] = await acme.crypto.createCsr(
{
altNames: altNames ? [...altNames] : [],
commonName: commonName || ""
},
skLeaf
);
certificateCsr = generatedCsr.toString();
}
const acmeResult = await acmeFns.orderCertificateFromProfile({
caId,
profileId,
commonName: commonName || "",
altNames: altNames || [],
csr: Buffer.from(certificateCsr),
csrPrivateKey: skLeaf,
keyUsages: keyUsages as CertKeyUsage[],
extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[],
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 azureParams = {
caId,
profileId,
commonName: commonName || "",
altNames: altNames || [],
keyUsages: keyUsages as CertKeyUsage[],
extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[],
validity: { ttl },
signatureAlgorithm,
keyAlgorithm: keyAlgorithm as CertKeyAlgorithm,
isRenewal,
originalCertificateId,
template,
...(csr && { csr })
};
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 () => {
await queueService.startPg(
QueueJobs.CaIssueCertificateFromProfile,
async ([job]) => {
const data = job.data as TIssueCertificateFromProfileJobData;
await processCertificateIssuanceJobs(data);
},
{
workerCount: 2,
batchSize: 1,
pollingIntervalSeconds: 1
}
);
logger.info("Certificate issuance queue worker initialized successfully");
};
return {
queueCertificateIssuance,
initializeCertificateIssuanceQueue,
processCertificateIssuanceJobs
};
};
@@ -67,6 +67,7 @@ import {
TGetCaDTO, TGetCaDTO,
TImportCertToCaDTO, TImportCertToCaDTO,
TIssueCertFromCaDTO, TIssueCertFromCaDTO,
TIssueCertFromCaResponse,
TRenewCaCertDTO, TRenewCaCertDTO,
TSignCertFromCaDTO, TSignCertFromCaDTO,
TSignIntermediateDTO, TSignIntermediateDTO,
@@ -1198,7 +1199,7 @@ export const internalCertificateAuthorityServiceFactory = ({
isFromProfile, isFromProfile,
internal = false, internal = false,
tx tx
}: TIssueCertFromCaDTO) => { }: TIssueCertFromCaDTO): Promise<TIssueCertFromCaResponse> => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined; let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined; let certificateTemplate: TCertificateTemplates | undefined;
let collectionId = pkiCollectionId; let collectionId = pkiCollectionId;
@@ -1532,10 +1533,11 @@ export const internalCertificateAuthorityServiceFactory = ({
return cert; return cert;
}; };
let cert;
if (tx) { if (tx) {
await executeIssueCertOperations(tx); cert = await executeIssueCertOperations(tx);
} else { } else {
await certificateDAL.transaction(executeIssueCertOperations); cert = await certificateDAL.transaction(executeIssueCertOperations);
} }
return { return {
@@ -1544,6 +1546,8 @@ export const internalCertificateAuthorityServiceFactory = ({
issuingCaCertificate, issuingCaCertificate,
privateKey: skLeaf, privateKey: skLeaf,
serialNumber, serialNumber,
certificateId: cert.id,
commonName,
ca: expandInternalCa(ca) ca: expandInternalCa(ca)
}; };
}; };
@@ -1571,15 +1575,16 @@ export const internalCertificateAuthorityServiceFactory = ({
keyUsages, keyUsages,
extendedKeyUsages, extendedKeyUsages,
signatureAlgorithm, signatureAlgorithm,
keyAlgorithm keyAlgorithm,
tx
} = dto; } = dto;
let collectionId = pkiCollectionId; let collectionId = pkiCollectionId;
if (caId) { if (caId) {
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
} else if (certificateTemplateId) { } else if (certificateTemplateId) {
certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId, tx);
if (!certificateTemplate) { if (!certificateTemplate) {
throw new NotFoundError({ throw new NotFoundError({
message: `Certificate template with ID '${certificateTemplateId}' not found` message: `Certificate template with ID '${certificateTemplateId}' not found`
@@ -1587,7 +1592,7 @@ export const internalCertificateAuthorityServiceFactory = ({
} }
collectionId = certificateTemplate.pkiCollectionId as string; collectionId = certificateTemplate.pkiCollectionId as string;
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId, tx);
} }
if (!ca) { if (!ca) {
@@ -1636,7 +1641,7 @@ export const internalCertificateAuthorityServiceFactory = ({
// check PKI collection // check PKI collection
if (pkiCollectionId) { if (pkiCollectionId) {
const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId, tx);
if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` });
if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" });
} }
@@ -1905,8 +1910,8 @@ export const internalCertificateAuthorityServiceFactory = ({
plainText: Buffer.from(certificateChainPem) plainText: Buffer.from(certificateChainPem)
}); });
await certificateDAL.transaction(async (tx) => { const createSignedCert = async (transaction: Knex) => {
const cert = await certificateDAL.create( const newCert = await certificateDAL.create(
{ {
caId: (ca as TCertificateAuthorities).id, caId: (ca as TCertificateAuthorities).id,
caCertId: caCert.id, caCertId: caCert.id,
@@ -1924,36 +1929,44 @@ export const internalCertificateAuthorityServiceFactory = ({
keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm, keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
}, },
tx transaction
); );
await certificateBodyDAL.create( await certificateBodyDAL.create(
{ {
certId: cert.id, certId: newCert.id,
encryptedCertificate, encryptedCertificate,
encryptedCertificateChain encryptedCertificateChain
}, },
tx transaction
); );
if (collectionId) { if (collectionId) {
await pkiCollectionItemDAL.create( await pkiCollectionItemDAL.create(
{ {
pkiCollectionId: collectionId, pkiCollectionId: collectionId,
certId: cert.id certId: newCert.id
}, },
tx transaction
); );
} }
return cert; return newCert;
}); };
let cert;
if (tx) {
cert = await createSignedCert(tx);
} else {
cert = await certificateDAL.transaction(createSignedCert);
}
return { return {
certificate: leafCert, certificate: leafCert,
certificateChain: certificateChainPem, certificateChain: certificateChainPem,
issuingCaCertificate, issuingCaCertificate,
serialNumber, serialNumber,
certificateId: cert.id,
ca: expandInternalCa(ca), ca: expandInternalCa(ca),
commonName: cn commonName: cn
}; };
@@ -160,6 +160,7 @@ export type TSignCertFromCaDTO =
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string; profileId?: string;
tx?: Knex;
} }
| ({ | ({
isInternal: false; isInternal: false;
@@ -179,6 +180,7 @@ export type TSignCertFromCaDTO =
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string; profileId?: string;
tx?: Knex;
} & Omit<TProjectPermission, "projectId">); } & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = { export type TGetCaCertificateTemplatesDTO = {
@@ -248,3 +250,20 @@ export type TIssueCertWithTemplateDTO = {
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[];
}; };
type TCaReference = {
id: string;
projectId: string;
dn: string;
};
export type TIssueCertFromCaResponse = {
certificate: string;
certificateChain: string;
issuingCaCertificate: string;
privateKey: string;
serialNumber: string;
certificateId: string;
ca: TCaReference;
commonName: string;
};
@@ -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;
@@ -0,0 +1,50 @@
import { z } from "zod";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
/**
* External configuration schema for Azure AD CS Certificate Authority
*/
export const AzureAdCsExternalConfigSchema = z.object({
template: z
.string()
.min(1, "Template name is required for Azure AD CS")
.describe("Certificate template name for Azure AD CS")
});
/**
* External configuration schema for ACME Certificate Authority
*/
export const AcmeExternalConfigSchema = z.object({});
/**
* Map of CA types to their corresponding external configuration schemas
*/
export const ExternalConfigSchemaMap = {
[CaType.AZURE_AD_CS]: AzureAdCsExternalConfigSchema,
[CaType.ACME]: AcmeExternalConfigSchema,
[CaType.INTERNAL]: z.object({}).optional() // Internal CAs don't use external configs
} as const;
export const createExternalConfigSchema = (caType?: CaType | null) => {
if (!caType || caType === CaType.INTERNAL) {
return z.object({}).nullable().optional();
}
const schema = ExternalConfigSchemaMap[caType];
if (!schema) {
return z.object({}).nullable().optional();
}
return schema.nullable().optional();
};
/**
* Union type of all possible external configuration schemas
*/
export const ExternalConfigUnionSchema = z
.union([AzureAdCsExternalConfigSchema, AcmeExternalConfigSchema, z.object({})])
.nullable()
.optional();
export type TExternalConfig = z.infer<typeof ExternalConfigUnionSchema>;
@@ -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;
@@ -50,6 +59,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
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,92 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TCertificateRequests, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
type TCertificateRequestWithCertificate = TCertificateRequests & {
certificate: TCertificates | 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 certificateRequestOrm.findById(id);
if (!certificateRequest) return null;
if (!certificateRequest.certificateId) {
return {
...certificateRequest,
certificate: null
};
}
const certificate = await db(TableName.Certificate)
.where("id", certificateRequest.certificateId)
.select(selectAllTableCols(TableName.Certificate))
.first();
return {
...certificateRequest,
certificate: certificate || 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,
tx?: Knex
): Promise<TCertificateRequests> => {
try {
const updateData: Partial<TCertificateRequests> = { status };
if (errorMessage !== undefined) {
updateData.errorMessage = errorMessage;
}
return await certificateRequestOrm.updateById(id, updateData, tx);
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate request status" });
}
};
const attachCertificate = async (id: string, certificateId: string, tx?: Knex): Promise<TCertificateRequests> => {
try {
return await certificateRequestOrm.updateById(
id,
{
certificateId,
status: "issued"
},
tx
);
} catch (error) {
throw new DatabaseError({ error, name: "Attach certificate to request" });
}
};
return {
...certificateRequestOrm,
findByIdWithCertificate,
findPendingByProjectId,
updateStatus,
attachCertificate
};
};
@@ -0,0 +1,563 @@
/* 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 { 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",
status: CertificateRequestStatus.PENDING
};
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(
{
status: CertificateRequestStatus.PENDING,
projectId: "550e8400-e29b-41d4-a716-446655440003",
profileId: "550e8400-e29b-41d4-a716-446655440004",
commonName: "test.example.com"
},
undefined
);
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(NotFoundError);
});
});
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({
id: "550e8400-e29b-41d4-a716-446655440006",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
id: "550e8400-e29b-41d4-a716-446655440006",
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({
id: "550e8400-e29b-41d4-a716-446655440008",
actor: ActorType.USER,
actorId: "550e8400-e29b-41d4-a716-446655440001",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
});
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
id: "550e8400-e29b-41d4-a716-446655440008",
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,284 @@
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
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 { ActorType } from "../auth/auth-type";
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>;
const certificateRequestDataSchema = z
.object({
profileId: z.string().uuid().optional(),
caId: z.string().uuid().optional(),
csr: z.string().min(1).optional(),
commonName: z.string().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(),
certificateId: z.string().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,
tx,
status,
...requestData
}: TCreateCertificateRequestDTO & { tx?: Knex }) => {
if (actor !== ActorType.ACME_ACCOUNT) {
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,
projectId,
...validatedData
},
tx
);
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 NotFoundError({ message: "Certificate request not found" });
}
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 NotFoundError({ message: "Certificate request not found" });
}
// 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({
id: certificateRequest.certificate.id,
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({
id: certificateRequest.certificate.id,
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,43 @@
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;
status: CertificateRequestStatus;
certificateId?: 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;
};
@@ -11,7 +11,7 @@ import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-ac
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; import { CertStatus } from "@app/services/certificate/certificate-types";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
@@ -19,7 +19,8 @@ import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertIncludeType, CertIncludeType,
CertKeyUsageType, CertKeyUsageType,
CertSubjectAttributeType CertSubjectAttributeType,
CertSubjectAlternativeNameType
} from "@app/services/certificate-common/certificate-constants"; } from "@app/services/certificate-common/certificate-constants";
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 } from "@app/services/certificate-profile/certificate-profile-types";
@@ -42,7 +43,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 +58,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 +67,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 +182,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 +196,13 @@ describe("CertificateV3Service", () => {
const mockTx = {}; const mockTx = {};
return callback(mockTx); return callback(mockTx);
}) })
} as any } as any,
certificateIssuanceQueue: {
queueCertificateIssuance: vi.fn().mockResolvedValue(undefined)
},
certificateRequestService: {
createCertificateRequest: vi.fn().mockResolvedValue({ id: "cert-req-123" })
}
}); });
}); });
@@ -267,6 +291,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "issuing-ca", issuingCaCertificate: "issuing-ca",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: { ca: {
id: "ca-123", id: "ca-123",
projectId: "project-123", projectId: "project-123",
@@ -326,8 +352,13 @@ describe("CertificateV3Service", () => {
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
const result = await service.issueCertificateFromProfile({ const result = await service.issueCertificateFromProfile({
profileId, profileId,
certificateRequest: mockCertificateRequest, certificateRequest: mockCertificateRequest,
@@ -461,6 +492,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "issuing-ca", issuingCaCertificate: "issuing-ca",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: { ca: {
id: "ca-123", id: "ca-123",
projectId: "project-123", projectId: "project-123",
@@ -503,8 +536,34 @@ describe("CertificateV3Service", () => {
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
id: "cert-1",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
commonName: "test.example.com",
friendlyName: "Test Algorithm Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-1",
certificateTemplateId: "template-1",
revokedAt: null,
altNames: null,
caCertId: null,
keyUsages: null,
extendedKeyUsages: null,
revocationReason: null,
pkiSubscriberId: null,
profileId: null
});
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
await service.issueCertificateFromProfile({ await service.issueCertificateFromProfile({
profileId, profileId,
certificateRequest: camelCaseRequest, certificateRequest: camelCaseRequest,
@@ -729,8 +788,13 @@ describe("CertificateV3Service", () => {
}); });
vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
const result = await service.signCertificateFromProfile({ const result = await service.signCertificateFromProfile({
profileId, profileId,
csr: mockCSR, csr: mockCSR,
@@ -790,7 +854,7 @@ describe("CertificateV3Service", () => {
describe("orderCertificateFromProfile", () => { describe("orderCertificateFromProfile", () => {
const mockCertificateOrder = { const mockCertificateOrder = {
altNames: [{ type: ACMESANType.DNS, value: "example.com" }], altNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }],
validity: { ttl: "30d" }, validity: { ttl: "30d" },
commonName: "example.com", commonName: "example.com",
keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
@@ -799,168 +863,6 @@ describe("CertificateV3Service", () => {
keyAlgorithm: "RSA_2048" keyAlgorithm: "RSA_2048"
}; };
it("should create order successfully for API enrollment profile", async () => {
const profileId = "profile-123";
const mockProfile = {
id: profileId,
projectId: "project-123",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
createdAt: new Date(),
updatedAt: new Date(),
slug: "test-profile-order",
description: "Test order profile",
estConfigId: null,
apiConfigId: null
};
const mockCA = {
id: "ca-123",
projectId: "project-123",
externalCa: undefined,
internalCa: {
id: "internal-ca-123",
parentCaId: null,
type: "ROOT",
friendlyName: "Test CA",
organization: "Test Org",
ou: "Test OU",
country: "US",
province: "CA",
locality: "SF",
commonName: "Test CA",
dn: "CN=Test CA",
serialNumber: "123",
maxPathLength: null,
keyAlgorithm: "RSA_2048",
notBefore: undefined,
notAfter: undefined,
activeCaCertId: "cert-123",
caId: "ca-123"
},
name: "Test CA",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
enableDirectIssuance: true
};
const mockTemplate = {
id: "template-123",
name: "Test Order Template",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-123",
description: "Test template for ordering certificates",
signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
keyAlgorithm: { defaultKeyType: "RSA_2048" },
attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
],
subject: undefined,
sans: undefined,
keyUsages: undefined,
extendedKeyUsages: undefined,
algorithms: undefined,
validity: undefined
};
const mockCertificateResult = {
certificate: "cert",
certificateChain: "chain",
issuingCaCertificate: "issuing-ca",
privateKey: "key",
serialNumber: "123456",
ca: {
id: "ca-123",
projectId: "project-123",
name: "Test CA",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
enableDirectIssuance: true,
externalCa: undefined,
internalCa: {
id: "internal-ca-123",
parentCaId: null,
type: "ROOT",
friendlyName: "Test CA",
organization: "Test Org",
ou: "Test OU",
country: "US",
province: "CA",
locality: "SF",
commonName: "Test CA",
dn: "CN=Test CA",
serialNumber: "123",
maxPathLength: null,
keyAlgorithm: "RSA_2048",
notBefore: null,
notAfter: null,
activeCaCertId: "cert-123",
caId: "ca-123"
}
}
};
const mockCertRecord = {
id: "cert-123",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-123",
commonName: "example.com",
friendlyName: "Test Order Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-123",
certificateTemplateId: "template-123",
revokedAt: null,
altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]),
caCertId: null,
keyUsages: ["DIGITAL_SIGNATURE"],
extendedKeyUsages: ["SERVER_AUTH"],
revocationReason: null,
pkiSubscriberId: null,
profileId: null
};
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
const result = await service.orderCertificateFromProfile({
profileId,
certificateOrder: mockCertificateOrder,
...mockActor
});
expect(result).toHaveProperty("orderId");
expect(result).toHaveProperty("status", "valid");
expect(result).toHaveProperty("certificate");
expect(result.subjectAlternativeNames).toHaveLength(1);
expect(result.subjectAlternativeNames[0]).toEqual({
type: ACMESANType.DNS,
value: "example.com",
status: CertificateOrderStatus.VALID
});
});
it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
const profileId = "profile-123"; const profileId = "profile-123";
const mockProfile = { const mockProfile = {
@@ -1097,6 +999,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "ca-cert", issuingCaCertificate: "ca-cert",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: rsaCa as any ca: rsaCa as any
}); });
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
@@ -1143,6 +1047,31 @@ describe("CertificateV3Service", () => {
pkiSubscriberId: null, pkiSubscriberId: null,
profileId: null profileId: null
}); });
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
id: "cert-1",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
commonName: "test.example.com",
friendlyName: "Test Algorithm Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-1",
certificateTemplateId: "template-1",
revokedAt: null,
altNames: null,
caCertId: null,
keyUsages: null,
extendedKeyUsages: null,
revocationReason: null,
pkiSubscriberId: null,
profileId: null
});
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
// Should not throw - RSA CA is compatible with RSA signature algorithms // Should not throw - RSA CA is compatible with RSA signature algorithms
await expect( await expect(
@@ -1229,6 +1158,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "ca-cert", issuingCaCertificate: "ca-cert",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: ecCa as any ca: ecCa as any
}); });
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
@@ -1275,6 +1206,31 @@ describe("CertificateV3Service", () => {
pkiSubscriberId: null, pkiSubscriberId: null,
profileId: null profileId: null
}); });
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
id: "cert-1",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
commonName: "test.example.com",
friendlyName: "Test Algorithm Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-1",
certificateTemplateId: "template-1",
revokedAt: null,
altNames: null,
caCertId: null,
keyUsages: null,
extendedKeyUsages: null,
revocationReason: null,
pkiSubscriberId: null,
profileId: null
});
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
// Should not throw - EC CA is compatible with ECDSA signature algorithms // Should not throw - EC CA is compatible with ECDSA signature algorithms
await expect( await expect(
@@ -1361,6 +1317,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "ca-cert", issuingCaCertificate: "ca-cert",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: rsa8192Ca as any ca: rsa8192Ca as any
}); });
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
@@ -1407,6 +1365,31 @@ describe("CertificateV3Service", () => {
pkiSubscriberId: null, pkiSubscriberId: null,
profileId: null profileId: null
}); });
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
id: "cert-1",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
commonName: "test.example.com",
friendlyName: "Test Algorithm Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-1",
certificateTemplateId: "template-1",
revokedAt: null,
altNames: null,
caCertId: null,
keyUsages: null,
extendedKeyUsages: null,
revocationReason: null,
pkiSubscriberId: null,
profileId: null
});
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
// Should not throw - dynamic check supports new RSA key sizes // Should not throw - dynamic check supports new RSA key sizes
await expect( await expect(
@@ -1493,6 +1476,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "ca-cert", issuingCaCertificate: "ca-cert",
privateKey: "key", privateKey: "key",
serialNumber: "123456", serialNumber: "123456",
certificateId: "cert-1",
commonName: "test.example.com",
ca: newEcCa as any ca: newEcCa as any
}); });
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
@@ -1539,6 +1524,31 @@ describe("CertificateV3Service", () => {
pkiSubscriberId: null, pkiSubscriberId: null,
profileId: null profileId: null
}); });
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
id: "cert-1",
serialNumber: "123456",
status: "ACTIVE",
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
commonName: "test.example.com",
friendlyName: "Test Algorithm Cert",
notBefore: new Date(),
notAfter: new Date(),
caId: "ca-1",
certificateTemplateId: "template-1",
revokedAt: null,
altNames: null,
caCertId: null,
keyUsages: null,
extendedKeyUsages: null,
revocationReason: null,
pkiSubscriberId: null,
profileId: null
});
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
return callback(undefined as any);
});
// Should not throw - dynamic check supports new EC curves // Should not throw - dynamic check supports new EC curves
await expect( await expect(
@@ -1672,8 +1682,9 @@ describe("CertificateV3Service", () => {
}); });
it("should successfully renew eligible certificate", async () => { it("should successfully renew eligible certificate", async () => {
// Mock the initial findById call vi.mocked(mockCertificateDAL.findById)
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); .mockResolvedValueOnce(mockOriginalCert)
.mockResolvedValueOnce({ ...mockOriginalCert, id: "cert-456", serialNumber: "789012" });
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
@@ -1689,6 +1700,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "issuing-ca", issuingCaCertificate: "issuing-ca",
privateKey: "private-key", privateKey: "private-key",
serialNumber: "789012", serialNumber: "789012",
certificateId: "cert-456",
commonName: "test.example.com",
ca: mockCA ca: mockCA
}); });
@@ -2006,6 +2019,8 @@ describe("CertificateV3Service", () => {
issuingCaCertificate: "issuing-ca", issuingCaCertificate: "issuing-ca",
privateKey: "private-key", privateKey: "private-key",
serialNumber: "789012", serialNumber: "789012",
certificateId: "cert-456",
commonName: "test.example.com",
ca: mockCA ca: mockCA
}); });
@@ -19,11 +19,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { import {
CertExtendedKeyUsage,
CertificateOrderStatus,
CertKeyAlgorithm, CertKeyAlgorithm,
CertKeyType, CertKeyType,
CertKeyUsage,
CertSignatureAlgorithm, CertSignatureAlgorithm,
CertStatus CertStatus
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
@@ -59,15 +56,16 @@ import {
bufferToString, bufferToString,
buildCertificateSubjectFromTemplate, buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate, buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayFromLegacy,
convertExtendedKeyUsageArrayToLegacy, convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayFromLegacy,
convertKeyUsageArrayToLegacy, convertKeyUsageArrayToLegacy,
mapEnumsForValidation, mapEnumsForValidation,
normalizeDateForApi, normalizeDateForApi,
removeRootCaFromChain removeRootCaFromChain
} from "../certificate-common/certificate-utils"; } from "../certificate-common/certificate-utils";
import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service";
import { CertificateRequestStatus } from "../certificate-request/certificate-request-types";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types";
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils"; import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils";
@@ -85,11 +83,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 +107,16 @@ 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"
>;
certificateRequestService: Pick<TCertificateRequestServiceFactory, "createCertificateRequest">;
}; };
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>; export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
@@ -292,16 +304,62 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
return bufferToString(certData as unknown as Buffer); return bufferToString(certData as unknown as Buffer);
}; };
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { const parseKeyUsages = (keyUsages: unknown): CertKeyUsageType[] => {
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(CertKeyUsageType);
if (Array.isArray(keyUsages)) {
return keyUsages.filter(
(usage): usage is CertKeyUsageType =>
typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsageType)
);
}
if (typeof keyUsages === "string") {
return keyUsages
.split(",")
.map((usage) => usage.trim())
.filter((usage): usage is CertKeyUsageType => validKeyUsages.includes(usage as CertKeyUsageType));
}
return [];
}; };
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsageType[] => {
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(CertExtendedKeyUsageType);
if (Array.isArray(extendedKeyUsages)) {
return extendedKeyUsages.filter(
(usage): usage is CertExtendedKeyUsageType =>
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType)
);
}
if (typeof extendedKeyUsages === "string") {
return extendedKeyUsages
.split(",")
.map((usage) => usage.trim())
.filter((usage): usage is CertExtendedKeyUsageType =>
validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType)
);
}
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 +499,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 +813,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 +858,9 @@ export const certificateV3ServiceFactory = ({
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue, pkiSyncQueue,
kmsService, kmsService,
projectDAL projectDAL,
certificateIssuanceQueue,
certificateRequestService
}: TCertificateV3ServiceFactoryDep) => { }: TCertificateV3ServiceFactoryDep) => {
const issueCertificateFromProfile = async ({ const issueCertificateFromProfile = async ({
profileId, profileId,
@@ -857,7 +944,7 @@ export const certificateV3ServiceFactory = ({
const result = await certificateDAL.transaction(async (tx) => { const result = await certificateDAL.transaction(async (tx) => {
const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm);
return processSelfSignedCertificate({ const selfSignedResult = await processSelfSignedCertificate({
certificateRequest, certificateRequest,
template, template,
profile, profile,
@@ -869,9 +956,31 @@ export const certificateV3ServiceFactory = ({
projectDAL, projectDAL,
tx tx
}); });
const certRequestResult = await certificateRequestService.createCertificateRequest({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId: profile.projectId,
tx,
profileId: profile.id,
commonName: certificateRequest.commonName,
altNames: certificateRequest.altNames?.map((san) => san.value).join(","),
keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages),
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages),
notBefore: certificateRequest.notBefore,
notAfter: certificateRequest.notAfter,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: effectiveSignatureAlgorithm,
status: CertificateRequestStatus.ISSUED,
certificateId: selfSignedResult.certificateData.id
}); });
const { selfSignedResult, certificateData } = result; return { ...selfSignedResult, certificateRequestId: certRequestResult.id };
});
const { selfSignedResult, certificateData, certificateRequestId } = result;
const subjectCommonName = const subjectCommonName =
(selfSignedResult.certificateSubject.common_name as string) || (selfSignedResult.certificateSubject.common_name as string) ||
@@ -897,6 +1006,7 @@ export const certificateV3ServiceFactory = ({
privateKey: selfSignedResult.privateKey.toString("utf8"), privateKey: selfSignedResult.privateKey.toString("utf8"),
serialNumber: selfSignedResult.serialNumber, serialNumber: selfSignedResult.serialNumber,
certificateId: certificateData.id, certificateId: certificateData.id,
certificateRequestId,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug, profileName: profile.slug,
commonName: subjectCommonName commonName: subjectCommonName
@@ -915,8 +1025,16 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "direct certificate issuance"); validateCaSupport(ca, "direct certificate issuance");
validateAlgorithmCompatibility(ca, template); validateAlgorithmCompatibility(ca, template);
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = const {
await internalCaService.issueCertFromCa({ certificate,
certificateChain,
issuingCaCertificate,
privateKey,
serialNumber,
cert,
certificateRequestId
} = await certificateDAL.transaction(async (tx) => {
const certResult = await internalCaService.issueCertFromCa({
caId: ca.id, caId: ca.id,
friendlyName: certificateSubject.common_name || "Certificate", friendlyName: certificateSubject.common_name || "Certificate",
commonName: certificateSubject.common_name || "", commonName: certificateSubject.common_name || "",
@@ -932,25 +1050,50 @@ export const certificateV3ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
isFromProfile: true isFromProfile: true,
tx
}); });
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); const certificateRecord = await certificateDAL.findById(certResult.certificateId, tx);
if (!cert) { if (!certificateRecord) {
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
} }
const finalRenewBeforeDays = calculateFinalRenewBeforeDays( const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
profile, profile,
certificateRequest.validity.ttl, certificateRequest.validity.ttl,
new Date(cert.notAfter) new Date(certificateRecord.notAfter)
); );
const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; const updateData: { profileId: string; renewBeforeDays?: number } = { profileId };
if (finalRenewBeforeDays !== undefined) { if (finalRenewBeforeDays !== undefined) {
updateData.renewBeforeDays = finalRenewBeforeDays; updateData.renewBeforeDays = finalRenewBeforeDays;
} }
await certificateDAL.updateById(cert.id, updateData); await certificateDAL.updateById(certificateRecord.id, updateData, tx);
const certRequestResult = await certificateRequestService.createCertificateRequest({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId: profile.projectId,
tx,
caId: ca.id,
profileId: profile.id,
commonName: certificateRequest.commonName,
altNames: certificateRequest.altNames?.map((san) => san.value).join(","),
keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages),
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages),
notBefore: certificateRequest.notBefore,
notAfter: certificateRequest.notAfter,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: effectiveSignatureAlgorithm,
status: CertificateRequestStatus.ISSUED,
certificateId: certResult.certificateId
});
return { ...certResult, cert: certificateRecord, certificateRequestId: certRequestResult.id };
});
let finalCertificateChain = bufferToString(certificateChain); let finalCertificateChain = bufferToString(certificateChain);
if (removeRootsFromChain) { if (removeRootsFromChain) {
@@ -964,6 +1107,7 @@ export const certificateV3ServiceFactory = ({
privateKey: bufferToString(privateKey), privateKey: bufferToString(privateKey),
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
certificateRequestId,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug, profileName: profile.slug,
commonName: cert.commonName || "" commonName: cert.commonName || ""
@@ -1047,8 +1191,9 @@ export const certificateV3ServiceFactory = ({
const effectiveSignatureAlgorithm = extractedSignatureAlgorithm; const effectiveSignatureAlgorithm = extractedSignatureAlgorithm;
const effectiveKeyAlgorithm = extractedKeyAlgorithm; const effectiveKeyAlgorithm = extractedKeyAlgorithm;
const { certificate, certificateChain, issuingCaCertificate, serialNumber } = const { certificate, certificateChain, issuingCaCertificate, serialNumber, cert, certificateRequestId } =
await internalCaService.signCertFromCa({ await certificateDAL.transaction(async (tx) => {
const certResult = await internalCaService.signCertFromCa({
isInternal: true, isInternal: true,
caId: ca.id, caId: ca.id,
csr, csr,
@@ -1058,21 +1203,51 @@ export const certificateV3ServiceFactory = ({
notAfter: normalizeDateForApi(notAfter), notAfter: normalizeDateForApi(notAfter),
signatureAlgorithm: effectiveSignatureAlgorithm, signatureAlgorithm: effectiveSignatureAlgorithm,
keyAlgorithm: effectiveKeyAlgorithm, keyAlgorithm: effectiveKeyAlgorithm,
isFromProfile: true isFromProfile: true,
tx
}); });
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); const signedCertRecord = await certificateDAL.findById(certResult.certificateId, tx);
if (!cert) { if (!signedCertRecord) {
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" });
} }
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
profile,
validity.ttl,
new Date(signedCertRecord.notAfter)
);
const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId }; const updateData: { profileId: string; renewBeforeDays?: number } = { profileId };
if (finalRenewBeforeDays !== undefined) { if (finalRenewBeforeDays !== undefined) {
updateData2.renewBeforeDays = finalRenewBeforeDays; updateData.renewBeforeDays = finalRenewBeforeDays;
} }
await certificateDAL.updateById(cert.id, updateData2); await certificateDAL.updateById(signedCertRecord.id, updateData, tx);
const certRequestResult = await certificateRequestService.createCertificateRequest({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId: profile.projectId,
tx,
caId: ca.id,
profileId: profile.id,
csr,
commonName: mappedCertificateRequest.commonName,
altNames: mappedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","),
keyUsages: convertKeyUsageArrayToLegacy(mappedCertificateRequest.keyUsages),
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(mappedCertificateRequest.extendedKeyUsages),
notBefore,
notAfter,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: effectiveSignatureAlgorithm,
status: CertificateRequestStatus.ISSUED,
certificateId: certResult.certificateId
});
return { ...certResult, cert: signedCertRecord, certificateRequestId: certRequestResult.id };
});
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
@@ -1086,6 +1261,7 @@ export const certificateV3ServiceFactory = ({
certificateChain: certificateChainString, certificateChain: certificateChainString,
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
certificateRequestId,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug, profileName: profile.slug,
commonName: cert.commonName || "" commonName: cert.commonName || ""
@@ -1098,8 +1274,7 @@ export const certificateV3ServiceFactory = ({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId
removeRootsFromChain
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => { }: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
const profile = await validateProfileAndPermissions( const profile = await validateProfileAndPermissions(
profileId, profileId,
@@ -1113,37 +1288,41 @@ export const certificateV3ServiceFactory = ({
EnrollmentType.API EnrollmentType.API
); );
const certificateRequest = { let certificateRequest: TCertificateRequest;
let extractedKeyAlgorithm: string | undefined;
let extractedSignatureAlgorithm: string | undefined;
if (certificateOrder.csr) {
certificateRequest = extractCertificateRequestFromCSR(certificateOrder.csr);
const algorithms = extractAlgorithmsFromCSR(certificateOrder.csr);
extractedKeyAlgorithm = algorithms.keyAlgorithm;
extractedSignatureAlgorithm = algorithms.signatureAlgorithm;
certificateRequest.validity = certificateOrder.validity;
if (certificateOrder.notBefore && certificateOrder.notAfter) {
certificateRequest.notBefore = certificateOrder.notBefore;
certificateRequest.notAfter = certificateOrder.notAfter;
}
} else {
certificateRequest = {
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,
let certType: CertSubjectAlternativeNameType;
switch (san.type) {
case "dns":
certType = CertSubjectAlternativeNameType.DNS_NAME;
break;
case "ip":
certType = CertSubjectAlternativeNameType.IP_ADDRESS;
break;
default:
throw new BadRequestError({
message: `Unsupported Subject Alternative Name type: ${san.type as string}`
});
}
return {
type: certType,
value: san.value
};
}),
validity: certificateOrder.validity, validity: certificateOrder.validity,
notBefore: certificateOrder.notBefore, notBefore: certificateOrder.notBefore,
notAfter: certificateOrder.notAfter, notAfter: certificateOrder.notAfter,
signatureAlgorithm: certificateOrder.signatureAlgorithm, signatureAlgorithm: certificateOrder.signatureAlgorithm,
keyAlgorithm: certificateOrder.keyAlgorithm keyAlgorithm: certificateOrder.keyAlgorithm
}; };
}
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
if (certificateOrder.csr) {
mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm;
mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm;
}
const validationResult = await certificateTemplateV2Service.validateCertificateRequest( const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId, profile.certificateTemplateId,
mappedCertificateRequest mappedCertificateRequest
@@ -1169,42 +1348,61 @@ export const certificateV3ServiceFactory = ({
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType === CaType.INTERNAL) { if (caType === CaType.INTERNAL) {
const certificateResult = await issueCertificateFromProfile({ throw new BadRequestError({
profileId, message: "Certificate ordering is not supported for the specified CA type"
certificateRequest, });
}
if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
const orderId = randomUUID();
const certRequest = await certificateRequestService.createCertificateRequest({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
removeRootsFromChain projectId: profile.projectId,
caId: ca.id,
profileId: profile.id,
commonName: certificateOrder.commonName || "",
keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [],
extendedKeyUsages: certificateOrder.extendedKeyUsages
? convertEnumsToStringArray(certificateOrder.extendedKeyUsages)
: [],
keyAlgorithm: certificateOrder.keyAlgorithm || "",
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
altNames: certificateOrder.altNames?.map((san) => san.value).join(",") || "",
notBefore: certificateOrder.notBefore,
notAfter: certificateOrder.notAfter,
status: CertificateRequestStatus.PENDING
}); });
const orderId = randomUUID(); await certificateIssuanceQueue.queueCertificateIssuance({
certificateId: orderId,
profileId: profile.id,
caId: profile.caId || "",
ttl: certificateOrder.validity?.ttl || "1y",
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
keyAlgorithm: certificateRequest.keyAlgorithm || "",
commonName: certificateRequest.commonName || "",
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value) || [],
keyUsages: certificateRequest.keyUsages ? convertEnumsToStringArray(certificateRequest.keyUsages) : [],
extendedKeyUsages: certificateRequest.extendedKeyUsages
? convertEnumsToStringArray(certificateRequest.extendedKeyUsages)
: [],
certificateRequestId: certRequest.id,
csr: certificateOrder.csr
});
return { return {
orderId, certificateRequestId: certRequest.id,
status: CertificateOrderStatus.VALID, projectId: certRequest.projectId,
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ profileName: profile.slug
type: san.type,
value: san.value,
status: CertificateOrderStatus.VALID
})),
authorizations: [],
finalize: `/api/v1/cert-manager/certificates/orders/${orderId}/completed`,
certificate: certificateResult.certificate,
projectId: certificateResult.projectId,
profileName: certificateResult.profileName
}; };
} }
if (caType === CaType.ACME) {
throw new BadRequestError({ throw new BadRequestError({
message: "ACME certificate ordering via profiles is not yet implemented." message: "Certificate ordering is not supported for the specified CA type"
});
}
throw new BadRequestError({
message: `Certificate ordering is not supported for CA type: ${caType}`
}); });
}; };
@@ -1216,7 +1414,9 @@ export const certificateV3ServiceFactory = ({
actorOrgId, actorOrgId,
internal = false, internal = false,
removeRootsFromChain removeRootsFromChain
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => { }: Omit<TRenewCertificateDTO, "certificateRequestId"> & {
internal?: boolean;
}): Promise<TCertificateFromProfileResponse> => {
const renewalResult = await certificateDAL.transaction(async (tx) => { const renewalResult = await certificateDAL.transaction(async (tx) => {
const originalCert = await certificateDAL.findById(certificateId, tx); const originalCert = await certificateDAL.findById(certificateId, tx);
if (!originalCert) { if (!originalCert) {
@@ -1229,15 +1429,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 +1519,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
@@ -1329,42 +1548,10 @@ export const certificateV3ServiceFactory = ({
const certificateRequest = { const certificateRequest = {
commonName: originalCert.commonName || undefined, commonName: originalCert.commonName || undefined,
keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), keyUsages: parseKeyUsages(originalCert.keyUsages),
extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( extendedKeyUsages: 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 +1595,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",
@@ -1420,8 +1614,10 @@ export const certificateV3ServiceFactory = ({
ttl, ttl,
notBefore: normalizeDateForApi(notBefore), notBefore: normalizeDateForApi(notBefore),
notAfter: normalizeDateForApi(notAfter), notAfter: normalizeDateForApi(notAfter),
keyUsages: parseKeyUsages(originalCert.keyUsages), keyUsages: convertKeyUsageArrayToLegacy(parseKeyUsages(originalCert.keyUsages)),
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(
parseExtendedKeyUsages(originalCert.extendedKeyUsages)
),
signatureAlgorithm: originalSignatureAlgorithm, signatureAlgorithm: originalSignatureAlgorithm,
keyAlgorithm: originalKeyAlgorithm, keyAlgorithm: originalKeyAlgorithm,
isFromProfile: true, isFromProfile: true,
@@ -1438,11 +1634,27 @@ export const certificateV3ServiceFactory = ({
issuingCaCertificate = caResult.issuingCaCertificate; issuingCaCertificate = caResult.issuingCaCertificate;
serialNumber = caResult.serialNumber; serialNumber = caResult.serialNumber;
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); const foundCert = await certificateDAL.findById(caResult.certificateId, tx);
if (!foundCert) { if (!foundCert) {
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(
@@ -1511,6 +1723,28 @@ export const certificateV3ServiceFactory = ({
await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx); await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx);
const certRequestResult = await certificateRequestService.createCertificateRequest({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId: originalCert.projectId,
tx,
caId: ca?.id || originalCert.caId || undefined,
profileId: originalCert.profileId || undefined,
commonName: originalCert.commonName || undefined,
altNames: originalCert.altNames || undefined,
keyUsages: parseKeyUsages(originalCert.keyUsages),
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
notBefore: new Date(newCert.notBefore),
notAfter: new Date(newCert.notAfter),
keyAlgorithm: originalKeyAlgorithm,
signatureAlgorithm: originalSignatureAlgorithm,
metadata: `Renewed from certificate ID: ${originalCert.id}`,
status: CertificateRequestStatus.ISSUED,
certificateId: newCert.id
});
return { return {
certificate, certificate,
certificateChain, certificateChain,
@@ -1518,10 +1752,76 @@ export const certificateV3ServiceFactory = ({
serialNumber, serialNumber,
newCert, newCert,
originalCert, originalCert,
profile profile,
certRequestResult
}; };
}); });
let certificateRequestId: string = renewalResult.certRequestResult?.id || "";
// 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())
: [];
const certificateRequest = await certificateRequestService.createCertificateRequest({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId: originalCert.projectId,
profileId: profile?.id,
caId: ca.id,
commonName: originalCert.commonName || undefined,
altNames: originalCert.altNames || undefined,
keyUsages: parseKeyUsages(originalCert.keyUsages),
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
keyAlgorithm: originalKeyAlgorithm,
signatureAlgorithm: originalSignatureAlgorithm,
metadata: `Renewed from certificate ID: ${originalCert.id}`,
status: CertificateRequestStatus.PENDING
});
certificateRequestId = certificateRequest.id;
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: certificateRequest.id
});
return {
certificate: "", // External CA renewal is async
certificateChain: "",
issuingCaCertificate: "",
serialNumber: "",
certificateId: renewalOrderId,
certificateRequestId: certificateRequest.id,
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,
@@ -1538,6 +1838,7 @@ export const certificateV3ServiceFactory = ({
certificateChain: finalCertificateChain, certificateChain: finalCertificateChain,
serialNumber: renewalResult.serialNumber, serialNumber: renewalResult.serialNumber,
certificateId: renewalResult.newCert.id, certificateId: renewalResult.newCert.id,
certificateRequestId,
projectId: renewalResult.originalCert.projectId, projectId: renewalResult.originalCert.projectId,
profileName: renewalResult.profile?.slug || "Self-signed Certificate", profileName: renewalResult.profile?.slug || "Self-signed Certificate",
commonName: renewalResult.originalCert.commonName || "" commonName: renewalResult.originalCert.commonName || ""
@@ -1,6 +1,5 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertKeyUsageType, CertKeyUsageType,
@@ -45,7 +44,7 @@ export type TOrderCertificateFromProfileDTO = {
profileId: string; profileId: string;
certificateOrder: { certificateOrder: {
altNames: Array<{ altNames: Array<{
type: ACMESANType; type: CertSubjectAlternativeNameType;
value: string; value: string;
}>; }>;
validity: { validity: {
@@ -58,6 +57,8 @@ export type TOrderCertificateFromProfileDTO = {
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
template?: string;
csr?: string;
}; };
removeRootsFromChain?: boolean; removeRootsFromChain?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -69,34 +70,14 @@ export type TCertificateFromProfileResponse = {
privateKey?: string; privateKey?: string;
serialNumber: string; serialNumber: string;
certificateId: string; certificateId: string;
certificateRequestId: string;
projectId: string; projectId: string;
profileName: string; profileName: string;
commonName: string; commonName: string;
}; };
export type TCertificateOrderResponse = { export type TCertificateOrderResponse = {
orderId: string; certificateRequestId: string;
status: CertificateOrderStatus;
subjectAlternativeNames: Array<{
type: ACMESANType;
value: string;
status: CertificateOrderStatus;
}>;
authorizations: Array<{
identifier: {
type: ACMESANType;
value: string;
};
status: CertificateOrderStatus;
expires?: string;
challenges: Array<{
type: string;
status: CertificateOrderStatus;
url: string;
token: string;
}>;
}>;
finalize: string;
certificate?: string; certificate?: string;
projectId: string; projectId: string;
profileName: string; profileName: string;
@@ -105,6 +86,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 = {
+4 -2
View File
@@ -189,10 +189,12 @@ export const useGetCaCertTemplates = (caId: string) => {
export const useGetAzureAdcsTemplates = ({ export const useGetAzureAdcsTemplates = ({
caId, caId,
projectId projectId,
isAzureAdcsCa
}: { }: {
caId: string; caId: string;
projectId: string; projectId: string;
isAzureAdcsCa: boolean;
}) => { }) => {
return useQuery({ return useQuery({
queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), queryKey: caKeys.getAzureAdcsTemplates(caId, projectId),
@@ -202,6 +204,6 @@ export const useGetAzureAdcsTemplates = ({
}>(`/api/v1/cert-manager/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); }>(`/api/v1/cert-manager/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`);
return data; return data;
}, },
enabled: Boolean(caId && projectId) enabled: Boolean(caId && projectId && isAzureAdcsCa)
}); });
}; };
@@ -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 = {
@@ -13,6 +13,8 @@ import {
TRenewCertificateDTO, TRenewCertificateDTO,
TRenewCertificateResponse, TRenewCertificateResponse,
TRevokeCertDTO, TRevokeCertDTO,
TUnifiedCertificateIssuanceDTO,
TUnifiedCertificateIssuanceResponse,
TUpdateRenewalConfigDTO TUpdateRenewalConfigDTO
} from "./types"; } from "./types";
@@ -185,3 +187,31 @@ 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/v1/cert-manager/certificates",
requestData
);
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)
});
}
});
};
@@ -7,7 +7,11 @@ import { TCertificate } 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) => {
@@ -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,59 @@ export type TDownloadPkcs12DTO = {
password: string; password: string;
alias: string; alias: string;
}; };
export type TUnifiedCertificateIssuanceDTO = {
projectSlug: string;
profileId: string;
projectId: string;
csr?: string;
attributes?: {
commonName?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
altNames?: Array<{
type: string;
value: string;
}>;
signatureAlgorithm: string;
keyAlgorithm: string;
subjectAlternativeNames?: Array<{
type: string;
value: string;
}>;
ttl: string;
notBefore?: string;
notAfter?: string;
};
removeRootsFromChain?: boolean;
};
export type TUnifiedCertificateResponse = {
certificate: {
certificate: string;
issuingCaCertificate: string;
certificateChain: string;
privateKey?: string;
serialNumber: string;
certificateId: string;
};
certificateRequestId: 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,11 @@ type Props = {
}; };
type TCertificateDetails = { type TCertificateDetails = {
serialNumber: string; serialNumber?: string;
certificate: string; certificate?: string;
certificateChain: string; certificateChain?: string;
privateKey: string; privateKey?: string;
issuingCaCertificate?: string;
}; };
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
@@ -122,12 +123,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 +243,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 +275,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,
attributes: {
ttl, ttl,
signatureAlgorithm, signatureAlgorithm: signatureAlgorithm || "",
keyAlgorithm, keyAlgorithm: 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.attributes.commonName = commonName;
} }
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.attributes.altNames = formattedSans;
} }
} }
const { serialNumber, certificate, certificateChain, privateKey } = const response = await issueCertificate(request);
await createCertificate(certificateRequest);
setCertificateDetails({ // Handle certificate issuance response
serialNumber,
certificate, if ("certificate" in response && response.certificate) {
certificateChain, const certData = response.certificate;
privateKey const certificateDetailsToSet = {
}); serialNumber: certData.serialNumber || "",
certificate: certData.certificate || "",
certificateChain: certData.certificateChain || "",
privateKey: certData.privateKey || "",
issuingCaCertificate: certData.issuingCaCertificate || ""
};
setCertificateDetails(certificateDetailsToSet);
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. Certificate 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 +347,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 +369,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 +524,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. Certificate Request ID: ${result.certificateRequestId}`
: "Certificate renewed successfully";
createNotification({ createNotification({
text: "Certificate renewed successfully", text: notificationText,
type: "success" type: "success"
}); });
@@ -125,7 +125,7 @@ export const CertificatesSection = () => {
onClick={() => handlePopUpOpen("issueCertificate")} onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Issue Request
</Button> </Button>
</div> </div>
)} )}
@@ -213,7 +213,8 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
// Fetch Azure ADCS templates when Azure CA is selected // Fetch Azure ADCS templates when Azure CA is selected
const { data: azureTemplates } = useGetAzureAdcsTemplates({ const { data: azureTemplates } = useGetAzureAdcsTemplates({
caId: selectedCa?.type === CaType.AZURE_AD_CS ? selectedCaId : "", caId: selectedCa?.type === CaType.AZURE_AD_CS ? selectedCaId : "",
projectId projectId,
isAzureAdcsCa: true
}); });
// Initialize form with ALL subscriber data including template // Initialize form with ALL subscriber data including template
@@ -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,28 @@ 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 || "",
isAzureAdcsCa
});
useEffect(() => { useEffect(() => {
if (isEdit && profile) { if (isEdit && profile) {
reset({ reset({
@@ -427,10 +476,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 +521,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 +549,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 +585,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 +686,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="Windows 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,
@@ -123,6 +124,8 @@ export const ProfileRow = ({
<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"
: profile.certificateAuthority?.isExternal
? profile.certificateAuthority.name
: caData?.configuration.friendlyName || : caData?.configuration.friendlyName ||
caData?.configuration.commonName || caData?.configuration.commonName ||
profile.caId} profile.caId}
@@ -179,7 +182,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 && (