mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 07:28:09 -05:00
Add external CA support, certificate requests and profile external configurations
This commit is contained in:
@@ -57,3 +57,4 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
|
||||
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
|
||||
docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62
|
||||
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
|
||||
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246
|
||||
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a
|
||||
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
|
||||
import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
|
||||
import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
|
||||
import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
|
||||
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
|
||||
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
|
||||
@@ -288,6 +289,7 @@ declare module "fastify" {
|
||||
auditLogStream: TAuditLogStreamServiceFactory;
|
||||
certificate: TCertificateServiceFactory;
|
||||
certificateV3: TCertificateV3ServiceFactory;
|
||||
certificateRequest: TCertificateRequestServiceFactory;
|
||||
certificateTemplate: TCertificateTemplateServiceFactory;
|
||||
certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
|
||||
certificateProfile: TCertificateProfileServiceFactory;
|
||||
|
||||
10
backend/src/@types/knex.d.ts
vendored
10
backend/src/@types/knex.d.ts
vendored
@@ -573,6 +573,11 @@ import {
|
||||
TWorkflowIntegrationsInsert,
|
||||
TWorkflowIntegrationsUpdate
|
||||
} from "@app/db/schemas";
|
||||
import {
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
} from "@app/db/schemas/certificate-requests";
|
||||
import {
|
||||
TAccessApprovalPoliciesEnvironments,
|
||||
TAccessApprovalPoliciesEnvironmentsInsert,
|
||||
@@ -714,6 +719,11 @@ declare module "knex/types/tables" {
|
||||
TExternalCertificateAuthoritiesUpdate
|
||||
>;
|
||||
[TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
|
||||
[TableName.CertificateRequests]: KnexOriginal.CompositeTableType<
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
>;
|
||||
[TableName.CertificateTemplate]: KnexOriginal.CompositeTableType<
|
||||
TCertificateTemplates,
|
||||
TCertificateTemplatesInsert,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
34
backend/src/db/schemas/certificate-requests.ts
Normal file
34
backend/src/db/schemas/certificate-requests.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const CertificateRequestsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
status: z.string(),
|
||||
projectId: z.string(),
|
||||
profileId: z.string().uuid().nullable().optional(),
|
||||
caId: z.string().uuid().nullable().optional(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
csr: z.string().nullable().optional(),
|
||||
commonName: z.string().nullable().optional(),
|
||||
altNames: z.string().nullable().optional(),
|
||||
keyUsages: z.string().array().nullable().optional(),
|
||||
extendedKeyUsages: z.string().array().nullable().optional(),
|
||||
notBefore: z.date().nullable().optional(),
|
||||
notAfter: z.date().nullable().optional(),
|
||||
keyAlgorithm: z.string().nullable().optional(),
|
||||
signatureAlgorithm: z.string().nullable().optional(),
|
||||
errorMessage: z.string().nullable().optional(),
|
||||
metadata: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TCertificateRequests = z.infer<typeof CertificateRequestsSchema>;
|
||||
export type TCertificateRequestsInsert = Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>;
|
||||
export type TCertificateRequestsUpdate = Partial<Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>>;
|
||||
@@ -16,6 +16,7 @@ export * from "./certificate-authority-certs";
|
||||
export * from "./certificate-authority-crl";
|
||||
export * from "./certificate-authority-secret";
|
||||
export * from "./certificate-bodies";
|
||||
export * from "./certificate-requests";
|
||||
export * from "./certificate-secrets";
|
||||
export * from "./certificate-syncs";
|
||||
export * from "./certificate-template-est-configs";
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum TableName {
|
||||
CertificateAuthorityCrl = "certificate_authority_crl",
|
||||
Certificate = "certificates",
|
||||
CertificateBody = "certificate_bodies",
|
||||
CertificateRequests = "certificate_requests",
|
||||
CertificateSecret = "certificate_secrets",
|
||||
CertificateTemplate = "certificate_templates",
|
||||
PkiCertificateTemplateV2 = "pki_certificate_templates_v2",
|
||||
|
||||
@@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
acmeConfigId: z.string().uuid().nullable().optional(),
|
||||
issuerType: z.string().default("ca")
|
||||
issuerType: z.string().default("ca"),
|
||||
externalConfigs: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
type TPkiAcmeServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
|
||||
@@ -776,6 +776,7 @@ export const pkiAcmeServiceFactory = ({
|
||||
const cert = await orderCertificate(
|
||||
{
|
||||
caId: certificateAuthority!.id,
|
||||
profileId,
|
||||
commonName: certificateRequest.commonName!,
|
||||
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
|
||||
csr: Buffer.from(csrPem),
|
||||
|
||||
@@ -63,6 +63,7 @@ export enum QueueName {
|
||||
DynamicSecretRevocation = "dynamic-secret-revocation",
|
||||
CaCrlRotation = "ca-crl-rotation",
|
||||
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
|
||||
CertificateIssuance = "certificate-issuance",
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
||||
PkiSync = "pki-sync",
|
||||
@@ -125,6 +126,7 @@ export enum QueueJobs {
|
||||
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
|
||||
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
|
||||
CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile",
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events",
|
||||
DailyReminders = "daily-reminders",
|
||||
@@ -343,6 +345,21 @@ export type TQueueJobTypes = {
|
||||
caType: CaType;
|
||||
};
|
||||
};
|
||||
[QueueName.CertificateIssuance]: {
|
||||
name: QueueJobs.CaIssueCertificateFromProfile;
|
||||
payload: {
|
||||
certificateId: string;
|
||||
profileId: string;
|
||||
caId: string;
|
||||
commonName?: string;
|
||||
altNames?: string[];
|
||||
ttl: string;
|
||||
signatureAlgorithm: string;
|
||||
keyAlgorithm: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
};
|
||||
};
|
||||
[QueueName.DailyReminders]: {
|
||||
name: QueueJobs.DailyReminders;
|
||||
payload: undefined;
|
||||
|
||||
@@ -173,6 +173,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author
|
||||
import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue";
|
||||
import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
|
||||
import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
|
||||
import { certificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue";
|
||||
import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
|
||||
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
|
||||
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
|
||||
@@ -180,6 +181,8 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif
|
||||
import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
|
||||
import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
|
||||
import { certificateRequestDALFactory } from "@app/services/certificate-request/certificate-request-dal";
|
||||
import { certificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
|
||||
import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
|
||||
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
|
||||
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
|
||||
@@ -1092,6 +1095,7 @@ export const registerRoutes = async (
|
||||
const certificateDAL = certificateDALFactory(db);
|
||||
const certificateBodyDAL = certificateBodyDALFactory(db);
|
||||
const certificateSecretDAL = certificateSecretDALFactory(db);
|
||||
const certificateRequestDAL = certificateRequestDALFactory(db);
|
||||
const certificateSyncDAL = certificateSyncDALFactory(db);
|
||||
|
||||
const pkiAlertDAL = pkiAlertDALFactory(db);
|
||||
@@ -1187,7 +1191,7 @@ export const registerRoutes = async (
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
@@ -2208,6 +2212,31 @@ export const registerRoutes = async (
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const certificateRequestService = certificateRequestServiceFactory({
|
||||
certificateRequestDAL,
|
||||
certificateDAL,
|
||||
certificateService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateIssuanceQueue = certificateIssuanceQueueFactory({
|
||||
certificateAuthorityDAL,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL,
|
||||
certificateRequestService
|
||||
});
|
||||
|
||||
const certificateV3Service = certificateV3ServiceFactory({
|
||||
certificateDAL,
|
||||
certificateSecretDAL,
|
||||
@@ -2222,7 +2251,8 @@ export const registerRoutes = async (
|
||||
pkiSyncQueue,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
certificateBodyDAL
|
||||
certificateBodyDAL,
|
||||
certificateIssuanceQueue
|
||||
});
|
||||
|
||||
const certificateV3Queue = certificateV3QueueServiceFactory({
|
||||
@@ -2448,6 +2478,7 @@ export const registerRoutes = async (
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await pkiAlertV2Queue.init();
|
||||
await certificateV3Queue.init();
|
||||
await certificateIssuanceQueue.initializeCertificateIssuanceQueue();
|
||||
await microsoftTeamsService.start();
|
||||
await dynamicSecretQueueService.init();
|
||||
await eventBusService.init();
|
||||
@@ -2513,6 +2544,7 @@ export const registerRoutes = async (
|
||||
auditLogStream: auditLogStreamService,
|
||||
certificate: certificateService,
|
||||
certificateV3: certificateV3Service,
|
||||
certificateRequest: certificateRequestService,
|
||||
certificateEstV3: certificateEstV3Service,
|
||||
sshCertificateAuthority: sshCertificateAuthorityService,
|
||||
sshCertificateTemplate: sshCertificateTemplateService,
|
||||
|
||||
@@ -46,7 +46,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
acmeConfig: z.object({}).optional(),
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -149,7 +150,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -204,6 +207,15 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfiles: PkiCertificateProfilesSchema.extend({
|
||||
certificateAuthority: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
name: z.string(),
|
||||
isExternal: z.boolean().optional(),
|
||||
externalType: z.string().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
metrics: z
|
||||
.object({
|
||||
profileId: z.string(),
|
||||
@@ -234,7 +246,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
id: z.string(),
|
||||
directoryUrl: z.string()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
}).array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
@@ -280,12 +293,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
}).extend({
|
||||
certificateAuthority: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectId: z.string().optional(),
|
||||
status: z.string(),
|
||||
name: z.string()
|
||||
name: z.string(),
|
||||
isExternal: z.boolean().optional(),
|
||||
externalType: z.string().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
certificateTemplate: z
|
||||
@@ -310,7 +327,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean(),
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -358,7 +376,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -412,7 +432,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean().default(false),
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -434,7 +455,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -479,7 +502,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: z.record(z.unknown()).nullable().optional()
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TCertificateRequests } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
CertKeyAlgorithm,
|
||||
CertSignatureAlgorithm
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
|
||||
import {
|
||||
CertExtendedKeyUsageType,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
|
||||
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
|
||||
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
|
||||
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
|
||||
|
||||
import { booleanSchema } from "../sanitizedSchemas";
|
||||
@@ -57,7 +61,398 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateCertificateRequestFlow = (data: {
|
||||
csr?: string;
|
||||
subjectAlternativeNames?: Array<{ type: ACMESANType; value: string }>;
|
||||
commonName?: string;
|
||||
altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>;
|
||||
}) => {
|
||||
const hasCSR = !!data.csr;
|
||||
const hasSANs = !!data.subjectAlternativeNames?.length;
|
||||
const hasStandardFields = !!(data.commonName || data.altNames?.length);
|
||||
|
||||
const flowCount = Number(hasCSR) + Number(hasSANs) + Number(hasStandardFields);
|
||||
return flowCount === 1;
|
||||
};
|
||||
|
||||
export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
projectId: z.string().uuid(),
|
||||
commonName: validateTemplateRegexField.optional(),
|
||||
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
|
||||
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
|
||||
altNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(CertSubjectAlternativeNameType),
|
||||
value: z.string().min(1, "SAN value cannot be empty")
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm),
|
||||
csr: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "CSR cannot be empty")
|
||||
.max(4096, "CSR cannot exceed 4096 characters")
|
||||
.optional(),
|
||||
subjectAlternativeNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
ttl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "TTL cannot be empty")
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
|
||||
notBefore: validateCaDateField.optional(),
|
||||
notAfter: validateCaDateField.optional(),
|
||||
removeRootsFromChain: booleanSchema.default(false).optional()
|
||||
})
|
||||
.refine(validateTtlAndDateFields, {
|
||||
message:
|
||||
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
|
||||
})
|
||||
.refine(validateDateOrder, {
|
||||
message: "notBefore must be earlier than notAfter"
|
||||
})
|
||||
.refine(validateCertificateRequestFlow, {
|
||||
message:
|
||||
"Must specify exactly one of: csr (for signing), subjectAlternativeNames (for ACME), or commonName/altNames (for issuance)"
|
||||
}),
|
||||
response: {
|
||||
200: z.union([
|
||||
z.object({
|
||||
certificate: z.string().trim(),
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
}),
|
||||
z.object({
|
||||
certificateRequestId: z.string(),
|
||||
status: z.string(),
|
||||
orderId: z.string().optional(),
|
||||
subjectAlternativeNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus)
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
authorizations: z
|
||||
.array(
|
||||
z.object({
|
||||
identifier: z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string()
|
||||
}),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
expires: z.string().optional(),
|
||||
challenges: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
finalize: z.string().optional()
|
||||
})
|
||||
])
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { csr, subjectAlternativeNames, ...requestBody } = req.body;
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
profileId: requestBody.profileId,
|
||||
csr,
|
||||
commonName: requestBody.commonName,
|
||||
altNames: requestBody.altNames ? JSON.stringify(requestBody.altNames) : undefined,
|
||||
keyUsages: requestBody.keyUsages,
|
||||
extendedKeyUsages: requestBody.extendedKeyUsages,
|
||||
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
|
||||
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
|
||||
keyAlgorithm: requestBody.keyAlgorithm,
|
||||
signatureAlgorithm: requestBody.signatureAlgorithm,
|
||||
metadata: JSON.stringify({
|
||||
ttl: requestBody.ttl,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain,
|
||||
subjectAlternativeNames
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
if (csr) {
|
||||
const extractedCsrData = extractCertificateRequestFromCSR(csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
csr,
|
||||
validity: { ttl: requestBody.ttl },
|
||||
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
|
||||
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: extractedCsrData.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
|
||||
const profile = await server.services.certificateProfile.getProfileById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId
|
||||
});
|
||||
|
||||
let useOrderFlow = false;
|
||||
if (profile?.caId) {
|
||||
const ca = await server.services.certificateAuthority.getCaById({
|
||||
caId: profile.caId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
useOrderFlow = caType !== CaType.INTERNAL;
|
||||
}
|
||||
|
||||
if (subjectAlternativeNames?.length || useOrderFlow) {
|
||||
let acmeAltNames = subjectAlternativeNames;
|
||||
if (useOrderFlow && !subjectAlternativeNames && requestBody.altNames?.length) {
|
||||
acmeAltNames = requestBody.altNames.map((alt) => ({
|
||||
type: (alt.type === CertSubjectAlternativeNameType.DNS_NAME
|
||||
? ACMESANType.DNS
|
||||
: ACMESANType.IP) as ACMESANType,
|
||||
value: alt.value
|
||||
}));
|
||||
}
|
||||
|
||||
const certificateOrderObject = {
|
||||
altNames: acmeAltNames || [],
|
||||
validity: { ttl: requestBody.ttl },
|
||||
commonName: requestBody.commonName,
|
||||
keyUsages: requestBody.keyUsages,
|
||||
extendedKeyUsages: requestBody.extendedKeyUsages,
|
||||
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
|
||||
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
|
||||
signatureAlgorithm: requestBody.signatureAlgorithm,
|
||||
keyAlgorithm: requestBody.keyAlgorithm
|
||||
};
|
||||
|
||||
const data = await server.services.certificateV3.orderCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
certificateOrder: certificateOrderObject,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain,
|
||||
certificateRequestId: certificateRequest.id
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.updateCertificateRequestStatus({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
errorMessage: undefined
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
orderId: data.orderId,
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificateRequestId: certificateRequest.id,
|
||||
...data
|
||||
};
|
||||
}
|
||||
const certificateRequestForService: CertificateRequestForService = {
|
||||
commonName: requestBody.commonName,
|
||||
keyUsages: requestBody.keyUsages,
|
||||
extendedKeyUsages: requestBody.extendedKeyUsages,
|
||||
altNames: requestBody.altNames,
|
||||
validity: { ttl: requestBody.ttl },
|
||||
notBefore: requestBody.notBefore ? new Date(requestBody.notBefore) : undefined,
|
||||
notAfter: requestBody.notAfter ? new Date(requestBody.notAfter) : undefined,
|
||||
signatureAlgorithm: requestBody.signatureAlgorithm,
|
||||
keyAlgorithm: requestBody.keyAlgorithm
|
||||
};
|
||||
|
||||
const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService);
|
||||
|
||||
const data = await server.services.certificateV3.issueCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
certificateRequest: mappedCertificateRequest,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
certificateId: data.certificateId,
|
||||
commonName: requestBody.commonName || "",
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
} catch (error) {
|
||||
await server.services.certificateRequest.updateCertificateRequestStatus({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error"
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/certificate-requests/:certificateRequestId/certificate",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
params: z.object({
|
||||
certificateRequestId: z.string().uuid()
|
||||
}),
|
||||
query: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.nativeEnum(CertificateRequestStatus),
|
||||
certificate: z.string().nullable(),
|
||||
privateKey: z.string().nullable(),
|
||||
serialNumber: z.string().nullable(),
|
||||
errorMessage: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const data = await server.services.certificateRequest.getCertificateFromRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: (req.query as { projectId: string }).projectId,
|
||||
certificateRequestId: req.params.certificateRequestId
|
||||
});
|
||||
|
||||
if (data.certificate && data.serialNumber) {
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: (req.query as { projectId: string }).projectId,
|
||||
event: {
|
||||
type: EventType.GET_CERT,
|
||||
metadata: {
|
||||
certId: req.params.certificateRequestId,
|
||||
cn: "",
|
||||
serialNumber: data.serialNumber || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/issue-certificate",
|
||||
@@ -65,8 +460,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -106,7 +503,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -138,6 +536,28 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
commonName: req.body.commonName,
|
||||
altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","),
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
keyAlgorithm: req.body.keyAlgorithm,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -152,7 +572,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,8 +586,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true, // Hide deprecated endpoint from docs
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -191,14 +616,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
@@ -215,6 +639,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
csr: req.body.csr,
|
||||
commonName: certificateRequestData.commonName,
|
||||
altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
|
||||
keyUsages: certificateRequestData.keyUsages,
|
||||
extendedKeyUsages: certificateRequestData.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
keyAlgorithm: certificateRequestData.keyAlgorithm,
|
||||
signatureAlgorithm: certificateRequestData.signatureAlgorithm
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -224,12 +673,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateProfileId: req.body.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: certificateRequest.commonName || ""
|
||||
commonName: certificateRequestData.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,23 +692,23 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true, // Hide deprecated endpoint from docs
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "⚠️ DEPRECATED: Use POST /certificates instead. This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
subjectAlternativeNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
)
|
||||
.min(1, "At least one subject alternative name must be provided"),
|
||||
subjectAlternativeNames: z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
),
|
||||
ttl: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -308,34 +760,54 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
})
|
||||
),
|
||||
finalize: z.string(),
|
||||
certificate: z.string().optional()
|
||||
certificate: z.string().optional(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateOrderObject = {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
};
|
||||
|
||||
const data = await server.services.certificateV3.orderCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: req.body.profileId,
|
||||
certificateOrder: {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
},
|
||||
certificateOrder: certificateOrderObject,
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
commonName: req.body.commonName,
|
||||
altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -349,7 +821,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -377,36 +852,87 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const data = await server.services.certificateV3.renewCertificate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
certificateId: req.params.certificateId,
|
||||
removeRootsFromChain: req.body?.removeRootsFromChain
|
||||
});
|
||||
let certificateRequest: TCertificateRequests | undefined;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.RENEW_CERTIFICATE,
|
||||
metadata: {
|
||||
originalCertificateId: req.params.certificateId,
|
||||
newCertificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: data.commonName
|
||||
}
|
||||
try {
|
||||
const originalCertificate = await server.services.certificate.getCert({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
serialNumber: req.params.certificateId
|
||||
});
|
||||
if (!originalCertificate) {
|
||||
throw new NotFoundError({ message: "Original certificate not found" });
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: originalCertificate.cert.projectId,
|
||||
profileId: originalCertificate.cert.profileId || undefined,
|
||||
caId: originalCertificate.cert.caId ?? undefined,
|
||||
metadata: JSON.stringify({
|
||||
operation: "renewal",
|
||||
originalCertificateId: req.params.certificateId,
|
||||
removeRootsFromChain: req.body?.removeRootsFromChain
|
||||
})
|
||||
});
|
||||
|
||||
const data = await server.services.certificateV3.renewCertificate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
certificateId: req.params.certificateId,
|
||||
removeRootsFromChain: req.body?.removeRootsFromChain,
|
||||
certificateRequestId: certificateRequest.id
|
||||
});
|
||||
|
||||
if (data.certificate && data.certificate.trim() !== "") {
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
}
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.RENEW_CERTIFICATE,
|
||||
metadata: {
|
||||
originalCertificateId: req.params.certificateId,
|
||||
newCertificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: data.commonName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
} catch (error) {
|
||||
if (certificateRequest) {
|
||||
await server.services.certificateRequest.updateCertificateRequestStatus({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error during certificate renewal"
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import acme, { CsrBuffer } from "acme-client";
|
||||
import { Knex } from "knex";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
@@ -45,6 +47,60 @@ import {
|
||||
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
|
||||
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
|
||||
|
||||
const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
|
||||
const calculateFinalRenewBeforeDays = (
|
||||
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
|
||||
ttl: string
|
||||
): number | undefined => {
|
||||
if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const certificateTtlInDays = parseTtlToDays(ttl);
|
||||
const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays);
|
||||
|
||||
if (!renewBeforeDays) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return renewBeforeDays;
|
||||
};
|
||||
|
||||
type TAcmeCertificateAuthorityFnsDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
@@ -53,7 +109,7 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -64,13 +120,14 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type TOrderCertificateDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -78,6 +135,7 @@ type TOrderCertificateDeps = {
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type DBConfigurationColumn = {
|
||||
@@ -91,7 +149,7 @@ type DBConfigurationColumn = {
|
||||
|
||||
export const castDbEntryToAcmeCertificateAuthority = (
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
): TAcmeCertificateAuthority & { credentials: unknown } => {
|
||||
): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => {
|
||||
if (!ca.externalCa?.id) {
|
||||
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
|
||||
}
|
||||
@@ -123,15 +181,22 @@ export const castDbEntryToAcmeCertificateAuthority = (
|
||||
export const orderCertificate = async (
|
||||
{
|
||||
caId,
|
||||
profileId,
|
||||
subscriberId,
|
||||
commonName,
|
||||
altNames,
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId?: string;
|
||||
subscriberId?: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
@@ -139,6 +204,11 @@ export const orderCertificate = async (
|
||||
csrPrivateKey?: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
ttl?: string;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
},
|
||||
deps: TOrderCertificateDeps,
|
||||
tx?: Knex
|
||||
@@ -151,7 +221,8 @@ export const orderCertificate = async (
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
certificateProfileDAL
|
||||
} = deps;
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
|
||||
@@ -181,7 +252,7 @@ export const orderCertificate = async (
|
||||
let accountKey: Buffer | undefined;
|
||||
if (acmeCa.credentials) {
|
||||
const decryptedCredentials = await kmsDecryptor({
|
||||
cipherTextBlob: acmeCa.credentials as Buffer
|
||||
cipherTextBlob: acmeCa.credentials
|
||||
});
|
||||
|
||||
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
|
||||
@@ -322,6 +393,7 @@ export const orderCertificate = async (
|
||||
{
|
||||
caId: ca.id,
|
||||
pkiSubscriberId: subscriberId,
|
||||
profileId,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: commonName,
|
||||
commonName,
|
||||
@@ -331,11 +403,18 @@ export const orderCertificate = async (
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
projectId: ca.projectId
|
||||
keyAlgorithm,
|
||||
signatureAlgorithm,
|
||||
projectId: ca.projectId,
|
||||
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
|
||||
if (isRenewal && originalCertificateId) {
|
||||
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx);
|
||||
}
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
@@ -355,6 +434,26 @@ export const orderCertificate = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (profileId && ttl && certificateProfileDAL) {
|
||||
const profile = await certificateProfileDAL.findById(profileId, innerTx);
|
||||
if (profile) {
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
|
||||
profile as { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } },
|
||||
ttl
|
||||
);
|
||||
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
await certificateDAL.updateById(
|
||||
cert.id,
|
||||
{
|
||||
renewBeforeDays: finalRenewBeforeDays
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cert;
|
||||
});
|
||||
};
|
||||
@@ -371,7 +470,8 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
projectDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TAcmeCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -616,10 +716,71 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
};
|
||||
|
||||
const orderCertificateFromProfile = async ({
|
||||
caId,
|
||||
profileId,
|
||||
commonName,
|
||||
altNames = [],
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId?: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
csr: CsrBuffer;
|
||||
csrPrivateKey: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
ttl?: string;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
}) => {
|
||||
return orderCertificate(
|
||||
{
|
||||
caId,
|
||||
profileId,
|
||||
subscriberId: undefined,
|
||||
commonName,
|
||||
altNames,
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
},
|
||||
{
|
||||
appConnectionDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
certificateProfileDAL
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateAuthority,
|
||||
updateCertificateAuthority,
|
||||
listCertificateAuthorities,
|
||||
orderSubscriberCertificate
|
||||
orderSubscriberCertificate,
|
||||
orderCertificateFromProfile
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
CertExtendedKeyUsage,
|
||||
CertKeyAlgorithm,
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
CertStatus,
|
||||
TAltNameType
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
|
||||
@@ -42,6 +44,60 @@ import {
|
||||
TUpdateAzureAdCsCertificateAuthorityDTO
|
||||
} from "./azure-ad-cs-certificate-authority-types";
|
||||
|
||||
const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
|
||||
const calculateFinalRenewBeforeDays = (
|
||||
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
|
||||
ttl: string
|
||||
): number | undefined => {
|
||||
const hasAutoRenewEnabled = profile?.apiConfig?.autoRenew === true;
|
||||
if (!hasAutoRenewEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profileRenewBeforeDays = profile?.apiConfig?.renewBeforeDays;
|
||||
if (profileRenewBeforeDays !== undefined) {
|
||||
const certificateTtlInDays = parseTtlToDays(ttl);
|
||||
return calculateRenewalThreshold(profileRenewBeforeDays, certificateTtlInDays);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
@@ -50,7 +106,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -61,6 +117,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type AzureCertificateRequest = {
|
||||
@@ -190,7 +247,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie
|
||||
|
||||
export const castDbEntryToAzureAdCsCertificateAuthority = (
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
): TAzureAdCsCertificateAuthority & { credentials: unknown } => {
|
||||
): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => {
|
||||
if (!ca.externalCa?.id) {
|
||||
throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
|
||||
}
|
||||
@@ -591,7 +648,8 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
projectDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TAzureAdCsCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -1043,6 +1101,384 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
};
|
||||
};
|
||||
|
||||
const orderCertificateFromProfile = async ({
|
||||
caId,
|
||||
profileId,
|
||||
commonName,
|
||||
altNames = [],
|
||||
keyUsages = [],
|
||||
extendedKeyUsages = [],
|
||||
template,
|
||||
validity,
|
||||
notBefore,
|
||||
notAfter,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm = CertKeyAlgorithm.RSA_2048,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
template?: string;
|
||||
validity: { ttl: string };
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: CertKeyAlgorithm;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
}) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) {
|
||||
throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" });
|
||||
}
|
||||
|
||||
const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca);
|
||||
if (azureCa.status !== CaStatus.ACTIVE) {
|
||||
throw new BadRequestError({ message: "CA is disabled" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } =
|
||||
await getAzureADCSConnectionCredentials(
|
||||
azureCa.configuration.azureAdcsConnectionId,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
sslRejectUnauthorized?: boolean;
|
||||
sslCertificate?: string;
|
||||
} = {
|
||||
username,
|
||||
password,
|
||||
sslRejectUnauthorized,
|
||||
sslCertificate
|
||||
};
|
||||
|
||||
let alg;
|
||||
if (signatureAlgorithm) {
|
||||
switch (signatureAlgorithm.toUpperCase()) {
|
||||
case "RSA-SHA256":
|
||||
case "SHA256WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
break;
|
||||
case "RSA-SHA384":
|
||||
case "SHA384WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_3072);
|
||||
break;
|
||||
case "RSA-SHA512":
|
||||
case "SHA512WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_4096);
|
||||
break;
|
||||
case "ECDSA-SHA256":
|
||||
case "SHA256WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P256);
|
||||
break;
|
||||
case "ECDSA-SHA384":
|
||||
case "SHA384WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P384);
|
||||
break;
|
||||
case "ECDSA-SHA512":
|
||||
case "SHA512WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P521);
|
||||
break;
|
||||
default:
|
||||
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
|
||||
}
|
||||
|
||||
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const subjectDN = buildSubjectDN(commonName);
|
||||
|
||||
let sanExtension = "";
|
||||
if (altNames && altNames.length > 0) {
|
||||
sanExtension = altNames.join(",");
|
||||
}
|
||||
|
||||
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: subjectDN,
|
||||
keys: leafKeys,
|
||||
signingAlgorithm: alg,
|
||||
...(sanExtension && {
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension(
|
||||
altNames.map((name) => ({ type: "dns" as TAltNameType, value: name })),
|
||||
false
|
||||
)
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const csrPem = csrObj.toString("pem");
|
||||
|
||||
let templateValue = template;
|
||||
if (!templateValue) {
|
||||
templateValue = "WebServer";
|
||||
}
|
||||
|
||||
const templateInput = templateValue.trim();
|
||||
if (!templateInput || templateInput.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate template name cannot be empty"
|
||||
});
|
||||
}
|
||||
|
||||
let validityPeriod: string | undefined;
|
||||
if (notBefore && notAfter) {
|
||||
if (notAfter <= notBefore) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate notAfter date must be after notBefore date"
|
||||
});
|
||||
}
|
||||
|
||||
const diffMs = notAfter.getTime() - notBefore.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
validityPeriod = `${diffDays}d`;
|
||||
} else if (notAfter) {
|
||||
const diffMs = notAfter.getTime() - Date.now();
|
||||
if (diffMs <= 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate notAfter date must be in the future"
|
||||
});
|
||||
}
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
validityPeriod = `${diffDays}d`;
|
||||
} else if (validity.ttl) {
|
||||
validityPeriod = validity.ttl;
|
||||
}
|
||||
|
||||
const certificateRequest: AzureCertificateRequest = {
|
||||
csr: csrPem,
|
||||
template: templateInput,
|
||||
attributes: {
|
||||
subject: subjectDN,
|
||||
...(sanExtension && { subjectAlternativeName: sanExtension }),
|
||||
...(validityPeriod && { validityPeriod })
|
||||
}
|
||||
};
|
||||
|
||||
let submissionResponse;
|
||||
const maxOidRetries = 3;
|
||||
let oidRetryCount = 0;
|
||||
|
||||
while (oidRetryCount <= maxOidRetries) {
|
||||
try {
|
||||
submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest);
|
||||
break;
|
||||
} catch (error) {
|
||||
const isOidError =
|
||||
error instanceof BadRequestError &&
|
||||
(error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type"));
|
||||
|
||||
if (isOidError && oidRetryCount < maxOidRetries) {
|
||||
oidRetryCount += 1;
|
||||
|
||||
const delay = 3000 * oidRetryCount;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!submissionResponse) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to submit certificate request after multiple attempts due to OID resolution issues"
|
||||
});
|
||||
}
|
||||
|
||||
if (submissionResponse.status === "denied") {
|
||||
throw new BadRequestError({ message: "Certificate request was denied by ADCS" });
|
||||
}
|
||||
|
||||
let certificatePem = "";
|
||||
|
||||
if (submissionResponse.status === "issued" && submissionResponse.certificate) {
|
||||
certificatePem = submissionResponse.certificate;
|
||||
} else {
|
||||
const maxRetries = 5;
|
||||
const initialDelay = 2000;
|
||||
let retryCount = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
retryCount++;
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
// Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s
|
||||
const delay = initialDelay * 2 ** (retryCount - 1);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount === maxRetries) {
|
||||
throw new BadRequestError({
|
||||
message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!certificatePem) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing."
|
||||
});
|
||||
}
|
||||
|
||||
let cleanedCertificatePem = certificatePem.trim();
|
||||
|
||||
if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid certificate format received from ADCS. Expected PEM format."
|
||||
});
|
||||
}
|
||||
|
||||
cleanedCertificatePem = cleanedCertificatePem
|
||||
.replace(new RE2("\\r\\n", "g"), "\n")
|
||||
.replace(new RE2("\\r", "g"), "\n")
|
||||
.trim();
|
||||
|
||||
if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid certificate format received from ADCS. Missing end marker."
|
||||
});
|
||||
}
|
||||
|
||||
let certObj: x509.X509Certificate;
|
||||
try {
|
||||
certObj = new x509.X509Certificate(cleanedCertificatePem);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.`
|
||||
});
|
||||
}
|
||||
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(certObj.rawData))
|
||||
});
|
||||
|
||||
const certificateChainPem = submissionResponse.certificateChain || "";
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
});
|
||||
|
||||
let certificateId: string;
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
profileId,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: commonName,
|
||||
commonName,
|
||||
altNames: altNames.join(","),
|
||||
serialNumber: certObj.serialNumber,
|
||||
notBefore: certObj.notBefore,
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
keyAlgorithm,
|
||||
signatureAlgorithm,
|
||||
projectId: ca.projectId,
|
||||
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
certificateId = cert.id;
|
||||
|
||||
if (isRenewal && originalCertificateId) {
|
||||
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx);
|
||||
}
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (profileId && validity?.ttl && certificateProfileDAL) {
|
||||
const profile = await certificateProfileDAL.findById(profileId, tx);
|
||||
if (profile) {
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(undefined, validity.ttl);
|
||||
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
await certificateDAL.updateById(
|
||||
cert.id,
|
||||
{
|
||||
renewBeforeDays: finalRenewBeforeDays
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: cleanedCertificatePem,
|
||||
certificateChain: certificateChainPem,
|
||||
privateKey: skLeaf,
|
||||
serialNumber: certObj.serialNumber,
|
||||
certificateId: certificateId!,
|
||||
ca: azureCa
|
||||
};
|
||||
};
|
||||
|
||||
const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca || ca.projectId !== projectId) {
|
||||
@@ -1182,6 +1618,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
updateCertificateAuthority,
|
||||
listCertificateAuthorities,
|
||||
orderSubscriberCertificate,
|
||||
orderCertificateFromProfile,
|
||||
getTemplates
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({
|
||||
azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID")
|
||||
});
|
||||
|
||||
export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
sslRejectUnauthorized: z.boolean().optional(),
|
||||
sslCertificate: z.string().optional()
|
||||
});
|
||||
|
||||
export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
|
||||
type: z.literal(CaType.AZURE_AD_CS),
|
||||
configuration: AzureAdCsCertificateAuthorityConfigurationSchema
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
@@ -62,7 +63,7 @@ type TCertificateAuthorityServiceFactoryDep = {
|
||||
internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -72,6 +73,7 @@ type TCertificateAuthorityServiceFactoryDep = {
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
|
||||
@@ -90,7 +92,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TCertificateAuthorityServiceFactoryDep) => {
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
@@ -104,7 +107,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
@@ -119,7 +123,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const createCertificateAuthority = async (
|
||||
@@ -487,12 +492,48 @@ export const certificateAuthorityServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const getCaById = async ({
|
||||
caId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: {
|
||||
caId: string;
|
||||
actor: OrgServiceActor["type"];
|
||||
actorId: string;
|
||||
actorAuthMethod: OrgServiceActor["authMethod"];
|
||||
actorOrgId?: string;
|
||||
}) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: "CA not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
return ca;
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateAuthority,
|
||||
findCertificateAuthorityByNameAndProjectId,
|
||||
listCertificateAuthoritiesByProjectId,
|
||||
updateCertificateAuthority,
|
||||
deleteCertificateAuthority,
|
||||
getAzureAdcsTemplates
|
||||
getAzureAdcsTemplates,
|
||||
getCaById
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import acme from "acme-client";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, TQueueServiceFactory } from "@app/queue";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
|
||||
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service";
|
||||
import { CertificateRequestStatus } from "../certificate-request/certificate-request-types";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
|
||||
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
|
||||
import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
|
||||
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
|
||||
import { CaType } from "./certificate-authority-enums";
|
||||
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal";
|
||||
|
||||
export type TIssueCertificateFromProfileJobData = {
|
||||
certificateId: string;
|
||||
profileId: string;
|
||||
caId: string;
|
||||
commonName?: string;
|
||||
altNames?: string[];
|
||||
ttl: string;
|
||||
signatureAlgorithm: string;
|
||||
keyAlgorithm: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
certificateRequestId?: string;
|
||||
};
|
||||
|
||||
type TCertificateIssuanceQueueFactoryDep = {
|
||||
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: TCertificateDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById" | "updateById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
certificateRequestService?: Pick<
|
||||
TCertificateRequestServiceFactory,
|
||||
"attachCertificateToRequest" | "updateCertificateRequestStatus"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TCertificateIssuanceQueueFactory = ReturnType<typeof certificateIssuanceQueueFactory>;
|
||||
|
||||
export const certificateIssuanceQueueFactory = ({
|
||||
certificateAuthorityDAL,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
queueService,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL,
|
||||
certificateRequestService
|
||||
}: TCertificateIssuanceQueueFactoryDep) => {
|
||||
const validateKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
|
||||
if (!keyUsages) return [];
|
||||
const validKeyUsages = Object.values(CertKeyUsage);
|
||||
|
||||
if (Array.isArray(keyUsages)) {
|
||||
return keyUsages.filter(
|
||||
(usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const validateExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
|
||||
if (!extendedKeyUsages) return [];
|
||||
const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage);
|
||||
|
||||
if (Array.isArray(extendedKeyUsages)) {
|
||||
return extendedKeyUsages.filter(
|
||||
(usage): usage is CertExtendedKeyUsage =>
|
||||
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const validateKeyAlgorithm = (keyAlgorithm: unknown): CertKeyAlgorithm | undefined => {
|
||||
if (typeof keyAlgorithm !== "string") return undefined;
|
||||
const validKeyAlgorithms = Object.values(CertKeyAlgorithm);
|
||||
return validKeyAlgorithms.includes(keyAlgorithm as CertKeyAlgorithm)
|
||||
? (keyAlgorithm as CertKeyAlgorithm)
|
||||
: undefined;
|
||||
};
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue a certificate issuance job using pgBoss
|
||||
*/
|
||||
const queueCertificateIssuance = async ({
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId
|
||||
}: TIssueCertificateFromProfileJobData) => {
|
||||
const jobData: TIssueCertificateFromProfileJobData = {
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId
|
||||
};
|
||||
|
||||
await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 5,
|
||||
retryBackoff: true
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Process certificate issuance jobs
|
||||
*/
|
||||
const processCertificateIssuanceJobs = async (data: TIssueCertificateFromProfileJobData) => {
|
||||
const {
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId
|
||||
} = data;
|
||||
|
||||
try {
|
||||
logger.info(`Processing certificate issuance job for [certificateId=${certificateId}] [caId=${caId}]`);
|
||||
|
||||
if (!caId) {
|
||||
throw new NotFoundError({
|
||||
message: `Certificate authority ID is required for external CA certificate issuance`
|
||||
});
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
|
||||
if (ca.externalCa?.type === CaType.ACME) {
|
||||
const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm);
|
||||
if (!validatedKeyAlgorithm) {
|
||||
throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` });
|
||||
}
|
||||
const keyAlg = keyAlgorithmToAlgCfg(validatedKeyAlgorithm);
|
||||
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]);
|
||||
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const [, certificateCsr] = await acme.crypto.createCsr(
|
||||
{
|
||||
altNames: altNames || [],
|
||||
commonName: commonName || ""
|
||||
},
|
||||
skLeaf
|
||||
);
|
||||
|
||||
const acmeResult = await acmeFns.orderCertificateFromProfile({
|
||||
caId,
|
||||
profileId,
|
||||
commonName: commonName || "",
|
||||
altNames: altNames || [],
|
||||
csr: certificateCsr,
|
||||
csrPrivateKey: skLeaf,
|
||||
keyUsages: validateKeyUsages(keyUsages),
|
||||
extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages),
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
});
|
||||
|
||||
if (certificateRequestId && certificateRequestService && acmeResult?.id) {
|
||||
try {
|
||||
await certificateRequestService.attachCertificateToRequest({
|
||||
certificateRequestId,
|
||||
certificateId: acmeResult.id
|
||||
});
|
||||
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
|
||||
} catch (attachError) {
|
||||
logger.error(
|
||||
attachError,
|
||||
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
|
||||
});
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ca.externalCa?.type === CaType.AZURE_AD_CS) {
|
||||
let template: string | undefined;
|
||||
if (certificateProfileDAL) {
|
||||
try {
|
||||
const profile = await certificateProfileDAL.findById(profileId);
|
||||
if (
|
||||
profile?.externalConfigs &&
|
||||
typeof profile.externalConfigs === "object" &&
|
||||
profile.externalConfigs !== null
|
||||
) {
|
||||
const configs = profile.externalConfigs;
|
||||
if (typeof configs.template === "string") {
|
||||
template = configs.template;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to fetch profile ${profileId} for template extraction: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const validatedKeyAlgorithm = validateKeyAlgorithm(keyAlgorithm);
|
||||
if (!validatedKeyAlgorithm) {
|
||||
throw new BadRequestError({ message: `Invalid key algorithm: ${keyAlgorithm}` });
|
||||
}
|
||||
|
||||
const azureParams = {
|
||||
caId,
|
||||
profileId,
|
||||
commonName: commonName || "",
|
||||
altNames: altNames || [],
|
||||
keyUsages: validateKeyUsages(keyUsages),
|
||||
extendedKeyUsages: validateExtendedKeyUsages(extendedKeyUsages),
|
||||
validity: { ttl },
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm: validatedKeyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
template
|
||||
};
|
||||
|
||||
const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams);
|
||||
|
||||
if (certificateRequestId && certificateRequestService && azureResult?.certificateId) {
|
||||
try {
|
||||
await certificateRequestService.attachCertificateToRequest({
|
||||
certificateRequestId,
|
||||
certificateId: azureResult.certificateId
|
||||
});
|
||||
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
|
||||
} catch (attachError) {
|
||||
logger.error(
|
||||
attachError,
|
||||
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
|
||||
});
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully processed certificate issuance job with [certificateId=${certificateId}] [caId=${caId}]`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`);
|
||||
|
||||
if (certificateRequestId && certificateRequestService) {
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Certificate issuance failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
logger.info(`Updated certificate request ${certificateRequestId} status to failed due to issuance error`);
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeCertificateIssuanceQueue = async () => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
await queueService.startPg(
|
||||
QueueJobs.CaIssueCertificateFromProfile,
|
||||
async ([job]) => {
|
||||
const data = job.data as TIssueCertificateFromProfileJobData;
|
||||
await processCertificateIssuanceJobs(data);
|
||||
},
|
||||
{
|
||||
workerCount: appCfg.NODE_ENV === "production" ? 3 : 1,
|
||||
batchSize: 1
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Certificate issuance queue worker initialized successfully");
|
||||
};
|
||||
|
||||
return {
|
||||
queueCertificateIssuance,
|
||||
initializeCertificateIssuanceQueue,
|
||||
processCertificateIssuanceJobs
|
||||
};
|
||||
};
|
||||
@@ -22,10 +22,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => {
|
||||
try {
|
||||
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [
|
||||
TCertificateProfile
|
||||
];
|
||||
return certificateProfile;
|
||||
const dataToInsert = {
|
||||
...data,
|
||||
externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null
|
||||
};
|
||||
|
||||
const [insertedProfile] = await (tx || db)(TableName.PkiCertificateProfile).insert(dataToInsert).returning("*");
|
||||
|
||||
return {
|
||||
...insertedProfile,
|
||||
externalConfigs: insertedProfile.externalConfigs
|
||||
? (JSON.parse(insertedProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create certificate profile" });
|
||||
}
|
||||
@@ -33,11 +42,25 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise<TCertificateProfile> => {
|
||||
try {
|
||||
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile)
|
||||
const dataToUpdate: Partial<Record<string, unknown>> = {
|
||||
...data
|
||||
};
|
||||
|
||||
if (data.externalConfigs !== undefined) {
|
||||
dataToUpdate.externalConfigs = data.externalConfigs ? JSON.stringify(data.externalConfigs) : null;
|
||||
}
|
||||
|
||||
const [updatedProfile] = await (tx || db)(TableName.PkiCertificateProfile)
|
||||
.where({ id })
|
||||
.update(data)
|
||||
.returning("*")) as [TCertificateProfile];
|
||||
return certificateProfile;
|
||||
.update(dataToUpdate)
|
||||
.returning("*");
|
||||
|
||||
return {
|
||||
...updatedProfile,
|
||||
externalConfigs: updatedProfile.externalConfigs
|
||||
? (JSON.parse(updatedProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update certificate profile" });
|
||||
}
|
||||
@@ -57,10 +80,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const findById = async (id: string, tx?: Knex): Promise<TCertificateProfile | undefined> => {
|
||||
try {
|
||||
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as
|
||||
| TCertificateProfile
|
||||
| undefined;
|
||||
return certificateProfile;
|
||||
const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first();
|
||||
|
||||
if (!certificateProfile) return undefined;
|
||||
|
||||
return {
|
||||
...certificateProfile,
|
||||
externalConfigs: certificateProfile.externalConfigs
|
||||
? (JSON.parse(certificateProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find certificate profile by id" });
|
||||
}
|
||||
@@ -203,6 +232,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
externalConfigs: result.externalConfigs
|
||||
? (JSON.parse(result.externalConfigs) as Record<string, unknown>)
|
||||
: null,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
@@ -277,6 +309,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
|
||||
const query = baseQuery
|
||||
.leftJoin(
|
||||
TableName.CertificateAuthority,
|
||||
`${TableName.PkiCertificateProfile}.caId`,
|
||||
`${TableName.CertificateAuthority}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ExternalCertificateAuthority,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`${TableName.ExternalCertificateAuthority}.caId`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiEstEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.estConfigId`,
|
||||
@@ -294,6 +336,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
|
||||
db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
|
||||
db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"),
|
||||
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
|
||||
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalCaType"),
|
||||
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
|
||||
db
|
||||
.ref("disableBootstrapCaValidation")
|
||||
@@ -337,6 +384,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const certificateAuthority = result.caId
|
||||
? {
|
||||
id: result.caId as string,
|
||||
name: result.caName as string,
|
||||
status: result.caStatus as string,
|
||||
isExternal: !!result.externalCaId,
|
||||
externalType: result.externalCaType as string | undefined
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const baseProfile = {
|
||||
id: result.id,
|
||||
projectId: result.projectId,
|
||||
@@ -349,11 +406,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
externalConfigs: result.externalConfigs
|
||||
? (JSON.parse(result.externalConfigs as string) as Record<string, unknown>)
|
||||
: null,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig,
|
||||
acmeConfig
|
||||
acmeConfig,
|
||||
certificateAuthority
|
||||
};
|
||||
|
||||
return baseProfile as TCertificateProfileWithConfigs;
|
||||
|
||||
@@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
||||
import { ActorType, AuthMethod } from "../auth/auth-type";
|
||||
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import type { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
|
||||
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
@@ -100,6 +100,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateId: "template-123",
|
||||
apiConfigId: "api-config-123",
|
||||
estConfigId: null,
|
||||
externalConfigs: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
@@ -229,17 +230,10 @@ describe("CertificateProfileService", () => {
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityDALFactory;
|
||||
|
||||
const mockCertificateAuthorityCertDAL = {
|
||||
create: vi.fn(),
|
||||
const mockExternalCertificateAuthorityDAL = {
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityCertDALFactory;
|
||||
findOne: vi.fn()
|
||||
} as unknown as Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ForbiddenError, "from").mockReturnValue({
|
||||
@@ -261,7 +255,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateBodyDAL: mockCertificateBodyDAL,
|
||||
certificateSecretDAL: mockCertificateSecretDAL,
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
|
||||
externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL,
|
||||
permissionService: mockPermissionService,
|
||||
licenseService: mockLicenseService,
|
||||
kmsService: mockKmsService,
|
||||
|
||||
@@ -19,8 +19,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { CaType } from "../certificate-authority/certificate-authority-enums";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
|
||||
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
@@ -68,6 +69,55 @@ const validateIssuerTypeConstraints = (
|
||||
}
|
||||
};
|
||||
|
||||
const validateTemplateByExternalCaType = (
|
||||
externalCaType: CaType | undefined,
|
||||
externalConfigs: Record<string, unknown> | null | undefined
|
||||
) => {
|
||||
if (!externalCaType) return;
|
||||
|
||||
switch (externalCaType) {
|
||||
case CaType.AZURE_AD_CS:
|
||||
if (!externalConfigs?.template || typeof externalConfigs.template !== "string") {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Azure ADCS Certificate Authority requires a template to be specified in external configs"
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const validateExternalConfigs = async (
|
||||
externalConfigs: Record<string, unknown> | null | undefined,
|
||||
caId: string | null,
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">,
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findOne">
|
||||
) => {
|
||||
if (!externalConfigs) return;
|
||||
|
||||
if (!caId) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "External configs can only be specified when a Certificate Authority is selected"
|
||||
});
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findById(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: "Certificate Authority not found" });
|
||||
}
|
||||
|
||||
const externalCa = await externalCertificateAuthorityDAL.findOne({ caId });
|
||||
|
||||
if (!externalCa) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "External configs can only be specified for external Certificate Authorities"
|
||||
});
|
||||
}
|
||||
|
||||
validateTemplateByExternalCaType(externalCa.type as CaType, externalConfigs);
|
||||
};
|
||||
|
||||
const generateAndEncryptAcmeEabSecret = async (
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
|
||||
@@ -180,7 +230,7 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
@@ -190,10 +240,22 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
|
||||
|
||||
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
|
||||
let parsedExternalConfigs: Record<string, unknown> | null = null;
|
||||
if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "string") {
|
||||
try {
|
||||
parsedExternalConfigs = JSON.parse(dalResult.externalConfigs) as Record<string, unknown>;
|
||||
} catch {
|
||||
parsedExternalConfigs = null;
|
||||
}
|
||||
} else if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "object") {
|
||||
parsedExternalConfigs = dalResult.externalConfigs as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {
|
||||
...dalResult,
|
||||
enrollmentType: dalResult.enrollmentType as EnrollmentType,
|
||||
issuerType: dalResult.issuerType as IssuerType
|
||||
issuerType: dalResult.issuerType as IssuerType,
|
||||
externalConfigs: parsedExternalConfigs
|
||||
} as TCertificateProfile;
|
||||
};
|
||||
|
||||
@@ -205,6 +267,8 @@ export const certificateProfileServiceFactory = ({
|
||||
acmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
@@ -272,6 +336,14 @@ export const certificateProfileServiceFactory = ({
|
||||
|
||||
validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null);
|
||||
|
||||
// Validate external configs
|
||||
await validateExternalConfigs(
|
||||
data.externalConfigs,
|
||||
data.caId ?? null,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL
|
||||
);
|
||||
|
||||
// Validate enrollment configuration requirements
|
||||
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
|
||||
throw new ForbiddenRequestError({
|
||||
@@ -340,7 +412,8 @@ export const certificateProfileServiceFactory = ({
|
||||
projectId,
|
||||
estConfigId,
|
||||
apiConfigId,
|
||||
acmeConfigId
|
||||
acmeConfigId,
|
||||
externalConfigs: data.externalConfigs
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -414,6 +487,16 @@ export const certificateProfileServiceFactory = ({
|
||||
|
||||
validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId);
|
||||
|
||||
// Validate external configs only if they are provided in the update
|
||||
if (data.externalConfigs !== undefined) {
|
||||
await validateExternalConfigs(
|
||||
data.externalConfigs,
|
||||
finalCaId ?? null,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL
|
||||
);
|
||||
}
|
||||
|
||||
const updatedData =
|
||||
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data;
|
||||
|
||||
@@ -558,9 +641,24 @@ export const certificateProfileServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Parse externalConfigs from JSON string to object if it exists
|
||||
let parsedExternalConfigs: Record<string, unknown> | null = null;
|
||||
if (profile.externalConfigs && typeof profile.externalConfigs === "string") {
|
||||
try {
|
||||
parsedExternalConfigs = JSON.parse(profile.externalConfigs) as Record<string, unknown>;
|
||||
} catch {
|
||||
// If parsing fails, leave as null
|
||||
parsedExternalConfigs = null;
|
||||
}
|
||||
} else if (profile.externalConfigs && typeof profile.externalConfigs === "object") {
|
||||
// Already an object, use as-is
|
||||
parsedExternalConfigs = profile.externalConfigs;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
enrollmentType: profile.enrollmentType as EnrollmentType
|
||||
enrollmentType: profile.enrollmentType as EnrollmentType,
|
||||
externalConfigs: parsedExternalConfigs
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,19 +15,28 @@ export enum IssuerType {
|
||||
SELF_SIGNED = "self-signed"
|
||||
}
|
||||
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType" | "externalConfigs"> & {
|
||||
enrollmentType: EnrollmentType;
|
||||
issuerType: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfileInsert = Omit<
|
||||
TPkiCertificateProfilesInsert,
|
||||
"enrollmentType" | "issuerType" | "externalConfigs"
|
||||
> & {
|
||||
enrollmentType: EnrollmentType;
|
||||
issuerType: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfileUpdate = Omit<
|
||||
TPkiCertificateProfilesUpdate,
|
||||
"enrollmentType" | "issuerType" | "externalConfigs"
|
||||
> & {
|
||||
enrollmentType?: EnrollmentType;
|
||||
issuerType?: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
estConfig?: {
|
||||
disableBootstrapCaValidation?: boolean;
|
||||
passphrase?: string;
|
||||
@@ -47,9 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
};
|
||||
certificateAuthority?: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
status: string;
|
||||
name: string;
|
||||
isExternal?: boolean;
|
||||
externalType?: string;
|
||||
};
|
||||
certificateTemplate?: {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TCertificateRequests } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
type TCertificateRequestWithCertificateFlat = TCertificateRequests & {
|
||||
certificateId?: string | null;
|
||||
certificateSerialNumber?: string | null;
|
||||
certificateFriendlyName?: string | null;
|
||||
certificateCommonName?: string | null;
|
||||
certificateAltNames?: string | null;
|
||||
certificateStatus?: string | null;
|
||||
certificateNotBefore?: Date | null;
|
||||
certificateNotAfter?: Date | null;
|
||||
certificateKeyUsages?: string[] | null;
|
||||
certificateExtendedKeyUsages?: string[] | null;
|
||||
};
|
||||
|
||||
type TCertificateInfo = {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
friendlyName: string | null;
|
||||
commonName: string;
|
||||
altNames: string | null;
|
||||
status: string;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
keyUsages: string[] | null;
|
||||
extendedKeyUsages: string[] | null;
|
||||
};
|
||||
|
||||
type TCertificateRequestWithCertificate = TCertificateRequests & {
|
||||
certificate: TCertificateInfo | null;
|
||||
};
|
||||
|
||||
export type TCertificateRequestDALFactory = ReturnType<typeof certificateRequestDALFactory>;
|
||||
|
||||
export const certificateRequestDALFactory = (db: TDbClient) => {
|
||||
const certificateRequestOrm = ormify(db, TableName.CertificateRequests);
|
||||
|
||||
const findByIdWithCertificate = async (id: string): Promise<TCertificateRequestWithCertificate | null> => {
|
||||
try {
|
||||
const certificateRequest = (await db(TableName.CertificateRequests)
|
||||
.leftJoin(
|
||||
TableName.Certificate,
|
||||
`${TableName.CertificateRequests}.certificateId`,
|
||||
`${TableName.Certificate}.id`
|
||||
)
|
||||
.where(`${TableName.CertificateRequests}.id`, id)
|
||||
.select(
|
||||
`${TableName.CertificateRequests}.*`,
|
||||
`${TableName.Certificate}.id as certificateId`,
|
||||
`${TableName.Certificate}.serialNumber as certificateSerialNumber`,
|
||||
`${TableName.Certificate}.friendlyName as certificateFriendlyName`,
|
||||
`${TableName.Certificate}.commonName as certificateCommonName`,
|
||||
`${TableName.Certificate}.altNames as certificateAltNames`,
|
||||
`${TableName.Certificate}.status as certificateStatus`,
|
||||
`${TableName.Certificate}.notBefore as certificateNotBefore`,
|
||||
`${TableName.Certificate}.notAfter as certificateNotAfter`,
|
||||
`${TableName.Certificate}.keyUsages as certificateKeyUsages`,
|
||||
`${TableName.Certificate}.extendedKeyUsages as certificateExtendedKeyUsages`
|
||||
)
|
||||
.first()) as TCertificateRequestWithCertificateFlat | undefined;
|
||||
|
||||
if (!certificateRequest) return null;
|
||||
|
||||
// Transform the flat result into nested structure
|
||||
const {
|
||||
certificateId,
|
||||
certificateSerialNumber,
|
||||
certificateFriendlyName,
|
||||
certificateCommonName,
|
||||
certificateAltNames,
|
||||
certificateStatus,
|
||||
certificateNotBefore,
|
||||
certificateNotAfter,
|
||||
certificateKeyUsages,
|
||||
certificateExtendedKeyUsages,
|
||||
...certificateRequestData
|
||||
} = certificateRequest;
|
||||
|
||||
return {
|
||||
...certificateRequestData,
|
||||
certificate: certificateId
|
||||
? {
|
||||
id: certificateId,
|
||||
serialNumber: certificateSerialNumber as string,
|
||||
friendlyName: certificateFriendlyName || null,
|
||||
commonName: certificateCommonName as string,
|
||||
altNames: certificateAltNames || null,
|
||||
status: certificateStatus as string,
|
||||
notBefore: certificateNotBefore as Date,
|
||||
notAfter: certificateNotAfter as Date,
|
||||
keyUsages: certificateKeyUsages || null,
|
||||
extendedKeyUsages: certificateExtendedKeyUsages || null
|
||||
}
|
||||
: null
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" });
|
||||
}
|
||||
};
|
||||
|
||||
const findPendingByProjectId = async (projectId: string): Promise<TCertificateRequests[]> => {
|
||||
try {
|
||||
return (await db(TableName.CertificateRequests)
|
||||
.where({ projectId, status: "pending" })
|
||||
.orderBy("createdAt", "desc")) as TCertificateRequests[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find pending certificate requests by project ID" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (id: string, status: string, errorMessage?: string): Promise<TCertificateRequests> => {
|
||||
try {
|
||||
const updateData: Partial<TCertificateRequests> = { status };
|
||||
if (errorMessage !== undefined) {
|
||||
updateData.errorMessage = errorMessage;
|
||||
}
|
||||
return await certificateRequestOrm.updateById(id, updateData);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update certificate request status" });
|
||||
}
|
||||
};
|
||||
|
||||
const attachCertificate = async (id: string, certificateId: string): Promise<TCertificateRequests> => {
|
||||
try {
|
||||
return await certificateRequestOrm.updateById(id, {
|
||||
certificateId,
|
||||
status: "issued"
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Attach certificate to request" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...certificateRequestOrm,
|
||||
findByIdWithCertificate,
|
||||
findPendingByProjectId,
|
||||
updateStatus,
|
||||
attachCertificate
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,561 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { createMongoAbility, ForbiddenError } from "@casl/ability";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { ActorType, AuthMethod } from "@app/services/auth/auth-type";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
|
||||
|
||||
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
|
||||
import { certificateRequestServiceFactory, TCertificateRequestServiceFactory } from "./certificate-request-service";
|
||||
import { CertificateRequestStatus } from "./certificate-request-types";
|
||||
|
||||
describe("CertificateRequestService", () => {
|
||||
let service: TCertificateRequestServiceFactory;
|
||||
|
||||
const mockCertificateRequestDAL: Pick<
|
||||
TCertificateRequestDALFactory,
|
||||
"create" | "findById" | "findByIdWithCertificate" | "updateStatus" | "attachCertificate"
|
||||
> = {
|
||||
create: vi.fn() as any,
|
||||
findById: vi.fn() as any,
|
||||
findByIdWithCertificate: vi.fn() as any,
|
||||
updateStatus: vi.fn() as any,
|
||||
attachCertificate: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockCertificateDAL: Pick<TCertificateDALFactory, "findById"> = {
|
||||
findById: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockCertificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey"> = {
|
||||
getCertBody: vi.fn() as any,
|
||||
getCertPrivateKey: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockPermissionService: Pick<TPermissionServiceFactory, "getProjectPermission"> = {
|
||||
getProjectPermission: vi.fn() as any
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = certificateRequestServiceFactory({
|
||||
certificateRequestDAL: mockCertificateRequestDAL as TCertificateRequestDALFactory,
|
||||
certificateDAL: mockCertificateDAL,
|
||||
certificateService: mockCertificateService,
|
||||
permissionService: mockPermissionService
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("createCertificateRequest", () => {
|
||||
const mockCreateData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
|
||||
it("should create certificate request successfully", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Create,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCreatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.create as any).mockResolvedValue(mockCreatedRequest);
|
||||
|
||||
const result = await service.createCertificateRequest(mockCreateData);
|
||||
|
||||
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com"
|
||||
})
|
||||
);
|
||||
expect(result).toEqual(mockCreatedRequest);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenError when user lacks permission", async () => {
|
||||
const mockPermission = {
|
||||
permission: ForbiddenError.from(createMongoAbility([]))
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
|
||||
await expect(service.createCertificateRequest(mockCreateData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCertificateRequest", () => {
|
||||
const mockGetData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
|
||||
};
|
||||
|
||||
it("should get certificate request successfully", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
|
||||
const result = await service.getCertificateRequest(mockGetData);
|
||||
|
||||
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440005");
|
||||
expect(result).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it("should throw BadRequestError when certificate request belongs to different project", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440099",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
|
||||
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCertificateFromRequest", () => {
|
||||
const mockGetData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
|
||||
};
|
||||
|
||||
it("should get certificate from request successfully when certificate is attached", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440006",
|
||||
serialNumber: "123456",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
const mockRequestWithCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: mockCertificate,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const mockCertBody = {
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
|
||||
};
|
||||
const mockPrivateKey = {
|
||||
certPrivateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
|
||||
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
|
||||
(mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440005"
|
||||
);
|
||||
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
|
||||
serialNumber: "123456",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
|
||||
serialNumber: "123456",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----",
|
||||
serialNumber: "123456",
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithCert.createdAt,
|
||||
updatedAt: mockRequestWithCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request successfully when no certificate is attached", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequestWithoutCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440007",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
certificate: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithoutCert.createdAt,
|
||||
updatedAt: mockRequestWithoutCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request successfully when private key access is denied", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440008",
|
||||
serialNumber: "123456",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
const mockRequestWithCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: mockCertificate,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const mockCertBody = {
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
|
||||
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
|
||||
(mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key access denied"));
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440005"
|
||||
);
|
||||
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
|
||||
serialNumber: "123456",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
|
||||
serialNumber: "123456",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
|
||||
privateKey: null,
|
||||
serialNumber: "123456",
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithCert.createdAt,
|
||||
updatedAt: mockRequestWithCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request with error message when failed", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockFailedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
certificate: null,
|
||||
errorMessage: "Certificate issuance failed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: "Certificate issuance failed",
|
||||
createdAt: mockFailedRequest.createdAt,
|
||||
updatedAt: mockFailedRequest.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCertificateFromRequest(mockGetData)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCertificateRequestStatus", () => {
|
||||
it("should update certificate request status successfully", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440011");
|
||||
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440011",
|
||||
CertificateRequestStatus.ISSUED,
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should update certificate request status with error message", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.FAILED
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: "Certificate issuance failed"
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440012",
|
||||
CertificateRequestStatus.FAILED,
|
||||
"Certificate issuance failed"
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440013",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachCertificateToRequest", () => {
|
||||
it("should attach certificate to request successfully", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440014",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440015"
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440014",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440015"
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateDAL.findById as any).mockResolvedValue(mockCertificate);
|
||||
(mockCertificateRequestDAL.attachCertificate as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440014",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440015"
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440014");
|
||||
expect(mockCertificateDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440015");
|
||||
expect(mockCertificateRequestDAL.attachCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440014",
|
||||
"550e8400-e29b-41d4-a716-446655440015"
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440016",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440017"
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate does not exist", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440018",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440018",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440019"
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
|
||||
|
||||
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
|
||||
import {
|
||||
CertificateRequestStatus,
|
||||
TAttachCertificateToRequestDTO,
|
||||
TCreateCertificateRequestDTO,
|
||||
TGetCertificateFromRequestDTO,
|
||||
TGetCertificateRequestDTO,
|
||||
TUpdateCertificateRequestStatusDTO
|
||||
} from "./certificate-request-types";
|
||||
|
||||
type TCertificateRequestServiceFactoryDep = {
|
||||
certificateRequestDAL: TCertificateRequestDALFactory;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findById">;
|
||||
certificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TCertificateRequestServiceFactory = ReturnType<typeof certificateRequestServiceFactory>;
|
||||
|
||||
// Input validation schemas
|
||||
const certificateRequestDataSchema = z
|
||||
.object({
|
||||
profileId: z.string().uuid().optional(),
|
||||
caId: z.string().uuid().optional(),
|
||||
csr: z.string().min(1).optional(),
|
||||
commonName: z.string().min(1).max(255).optional(),
|
||||
altNames: z.string().max(1000).optional(),
|
||||
keyUsages: z.array(z.string()).max(20).optional(),
|
||||
extendedKeyUsages: z.array(z.string()).max(20).optional(),
|
||||
notBefore: z.date().optional(),
|
||||
notAfter: z.date().optional(),
|
||||
keyAlgorithm: z.string().max(100).optional(),
|
||||
signatureAlgorithm: z.string().max(100).optional(),
|
||||
metadata: z.string().max(2000).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Must have either profileId or caId
|
||||
return data.profileId || data.caId;
|
||||
},
|
||||
{
|
||||
message: "Either profileId or caId must be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If notAfter is provided, it must be after notBefore
|
||||
if (data.notBefore && data.notAfter) {
|
||||
return data.notAfter > data.notBefore;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "notAfter must be after notBefore"
|
||||
}
|
||||
);
|
||||
|
||||
const validateCertificateRequestData = (data: unknown) => {
|
||||
try {
|
||||
return certificateRequestDataSchema.parse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new BadRequestError({
|
||||
message: `Invalid certificate request data: ${error.errors.map((e) => e.message).join(", ")}`
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const certificateRequestServiceFactory = ({
|
||||
certificateRequestDAL,
|
||||
certificateDAL,
|
||||
certificateService,
|
||||
permissionService
|
||||
}: TCertificateRequestServiceFactoryDep) => {
|
||||
const createCertificateRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
...requestData
|
||||
}: TCreateCertificateRequestDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Create,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
// Validate input data before creating the request
|
||||
const validatedData = validateCertificateRequestData(requestData);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.create({
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
projectId,
|
||||
...validatedData
|
||||
});
|
||||
|
||||
return certificateRequest;
|
||||
};
|
||||
|
||||
const getCertificateRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
certificateRequestId
|
||||
}: TGetCertificateRequestDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
if (certificateRequest.projectId !== projectId) {
|
||||
throw new BadRequestError({ message: "Certificate request does not belong to this project" });
|
||||
}
|
||||
|
||||
return certificateRequest;
|
||||
};
|
||||
|
||||
const getCertificateFromRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
certificateRequestId
|
||||
}: TGetCertificateFromRequestDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
if (certificateRequest.projectId !== projectId) {
|
||||
throw new BadRequestError({ message: "Certificate request does not belong to this project" });
|
||||
}
|
||||
|
||||
// If no certificate is attached, return basic info
|
||||
if (!certificateRequest.certificate) {
|
||||
return {
|
||||
status: certificateRequest.status as CertificateRequestStatus,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: certificateRequest.errorMessage || null,
|
||||
createdAt: certificateRequest.createdAt,
|
||||
updatedAt: certificateRequest.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Get certificate body (PEM data)
|
||||
const certBody = await certificateService.getCertBody({
|
||||
serialNumber: certificateRequest.certificate.serialNumber,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
// Try to get private key (may fail if user doesn't have permission)
|
||||
let privateKey: string | null = null;
|
||||
try {
|
||||
const certPrivateKey = await certificateService.getCertPrivateKey({
|
||||
serialNumber: certificateRequest.certificate.serialNumber,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
privateKey = certPrivateKey.certPrivateKey;
|
||||
} catch (error) {
|
||||
// Private key access denied - continue without it
|
||||
privateKey = null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: certificateRequest.status as CertificateRequestStatus,
|
||||
certificate: certBody.certificate,
|
||||
privateKey,
|
||||
serialNumber: certificateRequest.certificate.serialNumber,
|
||||
errorMessage: certificateRequest.errorMessage || null,
|
||||
createdAt: certificateRequest.createdAt,
|
||||
updatedAt: certificateRequest.updatedAt
|
||||
};
|
||||
};
|
||||
|
||||
const updateCertificateRequestStatus = async ({
|
||||
certificateRequestId,
|
||||
status,
|
||||
errorMessage
|
||||
}: TUpdateCertificateRequestStatusDTO) => {
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
return certificateRequestDAL.updateStatus(certificateRequestId, status, errorMessage);
|
||||
};
|
||||
|
||||
const attachCertificateToRequest = async ({
|
||||
certificateRequestId,
|
||||
certificateId
|
||||
}: TAttachCertificateToRequestDTO) => {
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
const certificate = await certificateDAL.findById(certificateId);
|
||||
if (!certificate) {
|
||||
throw new NotFoundError({ message: "Certificate not found" });
|
||||
}
|
||||
|
||||
return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId);
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateRequest,
|
||||
getCertificateRequest,
|
||||
getCertificateFromRequest,
|
||||
updateCertificateRequestStatus,
|
||||
attachCertificateToRequest
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum CertificateRequestStatus {
|
||||
PENDING = "pending",
|
||||
ISSUED = "issued",
|
||||
FAILED = "failed"
|
||||
}
|
||||
|
||||
export type TCreateCertificateRequestDTO = TProjectPermission & {
|
||||
profileId?: string;
|
||||
caId?: string;
|
||||
csr?: string;
|
||||
commonName?: string;
|
||||
altNames?: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
keyAlgorithm?: string;
|
||||
signatureAlgorithm?: string;
|
||||
metadata?: string;
|
||||
};
|
||||
|
||||
export type TGetCertificateRequestDTO = TProjectPermission & {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
|
||||
export type TGetCertificateFromRequestDTO = TProjectPermission & {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
|
||||
export type TUpdateCertificateRequestStatusDTO = {
|
||||
certificateRequestId: string;
|
||||
status: CertificateRequestStatus;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type TAttachCertificateToRequestDTO = {
|
||||
certificateRequestId: string;
|
||||
certificateId: string;
|
||||
};
|
||||
40
backend/src/services/certificate-v3/certificate-v3-fns.ts
Normal file
40
backend/src/services/certificate-v3/certificate-v3-fns.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import RE2 from "re2";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
export const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
// If renewBeforeDays >= TTL, renew 1 day before expiry
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
@@ -42,7 +42,7 @@ describe("CertificateV3Service", () => {
|
||||
|
||||
const mockCertificateDAL: Pick<
|
||||
TCertificateDALFactory,
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create"
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
|
||||
> = {
|
||||
findOne: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -57,7 +57,8 @@ describe("CertificateV3Service", () => {
|
||||
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
}),
|
||||
find: vi.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create"> = {
|
||||
@@ -65,12 +66,24 @@ describe("CertificateV3Service", () => {
|
||||
create: vi.fn()
|
||||
};
|
||||
|
||||
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
|
||||
findByIdWithAssociatedCa: vi.fn()
|
||||
const mockCertificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"findByIdWithAssociatedCa" | "create" | "updateById" | "findById" | "transaction" | "findWithAssociatedCa"
|
||||
> = {
|
||||
findByIdWithAssociatedCa: vi.fn(),
|
||||
create: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
updateById: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
findById: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
findWithAssociatedCa: vi.fn().mockResolvedValue([]),
|
||||
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
};
|
||||
|
||||
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs"> = {
|
||||
findByIdWithConfigs: vi.fn()
|
||||
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById"> = {
|
||||
findByIdWithConfigs: vi.fn(),
|
||||
findById: vi.fn()
|
||||
};
|
||||
|
||||
const mockCertificateTemplateV2Service: Pick<
|
||||
@@ -168,7 +181,11 @@ describe("CertificateV3Service", () => {
|
||||
kmsService: {
|
||||
generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"),
|
||||
encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))),
|
||||
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted")))
|
||||
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))),
|
||||
createCipherPairWithDataKey: vi.fn().mockResolvedValue({
|
||||
cipherTextBlob: Buffer.from("encrypted"),
|
||||
plainTextKey: Buffer.from("plainkey")
|
||||
})
|
||||
},
|
||||
projectDAL: {
|
||||
findOne: vi.fn().mockResolvedValue({ id: "project-123" }),
|
||||
@@ -178,7 +195,10 @@ describe("CertificateV3Service", () => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
} as any
|
||||
} as any,
|
||||
certificateIssuanceQueue: {
|
||||
queueCertificateIssuance: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
} from "@app/services/certificate-authority/certificate-authority-fns";
|
||||
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import {
|
||||
EnrollmentType,
|
||||
IssuerType,
|
||||
TCertificateProfileWithConfigs
|
||||
} from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
@@ -85,11 +89,17 @@ import {
|
||||
} from "./certificate-v3-types";
|
||||
|
||||
type TCertificateV3ServiceFactoryDep = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction" | "create">;
|
||||
certificateDAL: Pick<
|
||||
TCertificateDALFactory,
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
|
||||
>;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||
certificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById">;
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
|
||||
certificateTemplateV2Service: Pick<
|
||||
TCertificateTemplateV2ServiceFactory,
|
||||
@@ -103,8 +113,15 @@ type TCertificateV3ServiceFactoryDep = {
|
||||
>;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
projectDAL: TProjectDALFactory;
|
||||
certificateIssuanceQueue: Pick<
|
||||
import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory,
|
||||
"queueCertificateIssuance"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
|
||||
@@ -294,14 +311,57 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
|
||||
|
||||
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
|
||||
if (!keyUsages) return [];
|
||||
if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[];
|
||||
return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage);
|
||||
|
||||
const validKeyUsages = Object.values(CertKeyUsage);
|
||||
|
||||
if (Array.isArray(keyUsages)) {
|
||||
return keyUsages.filter(
|
||||
(usage): usage is CertKeyUsage => typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsage)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof keyUsages === "string") {
|
||||
return keyUsages
|
||||
.split(",")
|
||||
.map((usage) => usage.trim())
|
||||
.filter((usage): usage is CertKeyUsage => validKeyUsages.includes(usage as CertKeyUsage));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
|
||||
if (!extendedKeyUsages) return [];
|
||||
if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[];
|
||||
return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage);
|
||||
|
||||
const validExtendedKeyUsages = Object.values(CertExtendedKeyUsage);
|
||||
|
||||
if (Array.isArray(extendedKeyUsages)) {
|
||||
return extendedKeyUsages.filter(
|
||||
(usage): usage is CertExtendedKeyUsage =>
|
||||
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof extendedKeyUsages === "string") {
|
||||
return extendedKeyUsages
|
||||
.split(",")
|
||||
.map((usage) => usage.trim())
|
||||
.filter((usage): usage is CertExtendedKeyUsage => validExtendedKeyUsages.includes(usage as CertExtendedKeyUsage));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const convertEnumsToStringArray = <T extends string>(enumArray: T[]): string[] => {
|
||||
return enumArray.map((item) => item as string);
|
||||
};
|
||||
|
||||
const combineKeyUsageFlags = (keyUsages: string[]): number => {
|
||||
return keyUsages.reduce((acc: number, usage) => {
|
||||
const flag = x509.KeyUsageFlags[usage as keyof typeof x509.KeyUsageFlags];
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return typeof flag === "number" ? acc | flag : acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => {
|
||||
@@ -441,11 +501,7 @@ const generateSelfSignedCertificate = async ({
|
||||
...(certificateRequest.keyUsages?.length
|
||||
? [
|
||||
new x509.KeyUsagesExtension(
|
||||
(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(acc: number, usage) => acc | x509.KeyUsageFlags[usage],
|
||||
0
|
||||
),
|
||||
combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []),
|
||||
false
|
||||
)
|
||||
]
|
||||
@@ -759,6 +815,37 @@ const processSelfSignedCertificate = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const detectSanType = (value: string): { type: CertSubjectAlternativeNameType; value: string } => {
|
||||
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(value);
|
||||
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(value);
|
||||
|
||||
if (isIpv4 || isIpv6) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.IP_ADDRESS,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(value)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.EMAIL,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(value)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.URI,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.DNS_NAME,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
export const certificateV3ServiceFactory = ({
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
@@ -773,7 +860,8 @@ export const certificateV3ServiceFactory = ({
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
kmsService,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
certificateIssuanceQueue
|
||||
}: TCertificateV3ServiceFactoryDep) => {
|
||||
const issueCertificateFromProfile = async ({
|
||||
profileId,
|
||||
@@ -1099,7 +1187,8 @@ export const certificateV3ServiceFactory = ({
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
removeRootsFromChain
|
||||
removeRootsFromChain,
|
||||
certificateRequestId
|
||||
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
|
||||
const profile = await validateProfileAndPermissions(
|
||||
profileId,
|
||||
@@ -1117,7 +1206,7 @@ export const certificateV3ServiceFactory = ({
|
||||
commonName: certificateOrder.commonName,
|
||||
keyUsages: certificateOrder.keyUsages,
|
||||
extendedKeyUsages: certificateOrder.extendedKeyUsages,
|
||||
subjectAlternativeNames: certificateOrder.altNames.map((san) => {
|
||||
subjectAlternativeNames: certificateOrder.altNames?.map((san) => {
|
||||
let certType: CertSubjectAlternativeNameType;
|
||||
switch (san.type) {
|
||||
case "dns":
|
||||
@@ -1197,17 +1286,69 @@ export const certificateV3ServiceFactory = ({
|
||||
};
|
||||
}
|
||||
|
||||
if (caType === CaType.ACME) {
|
||||
throw new BadRequestError({
|
||||
message: "ACME certificate ordering via profiles is not yet implemented."
|
||||
if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
|
||||
const orderId = randomUUID();
|
||||
|
||||
await certificateIssuanceQueue.queueCertificateIssuance({
|
||||
certificateId: orderId,
|
||||
profileId: profile.id,
|
||||
caId: profile.caId || "",
|
||||
ttl: certificateOrder.validity?.ttl || "1y",
|
||||
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
|
||||
keyAlgorithm: certificateOrder.keyAlgorithm || "",
|
||||
commonName: certificateOrder.commonName || "",
|
||||
altNames: certificateOrder.altNames?.map((san) => san.value) || [],
|
||||
keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [],
|
||||
extendedKeyUsages: certificateOrder.extendedKeyUsages
|
||||
? convertEnumsToStringArray(certificateOrder.extendedKeyUsages)
|
||||
: [],
|
||||
certificateRequestId
|
||||
});
|
||||
|
||||
return {
|
||||
orderId,
|
||||
status: CertificateOrderStatus.PENDING,
|
||||
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
|
||||
type: san.type,
|
||||
value: san.value,
|
||||
status: CertificateOrderStatus.PENDING
|
||||
})),
|
||||
authorizations: [],
|
||||
finalize: `/api/v3/pki/certificates/orders/${orderId}/finalize`,
|
||||
projectId: profile.projectId,
|
||||
profileName: profile.slug
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Certificate ordering is not supported for CA type: ${caType}`
|
||||
message: "Certificate ordering is not supported for the specified CA type"
|
||||
});
|
||||
};
|
||||
|
||||
// Type for internal CA renewal result
|
||||
type TInternalRenewalData = {
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
issuingCaCertificate: string;
|
||||
serialNumber: string;
|
||||
newCert: TCertificates;
|
||||
originalCert: TCertificates;
|
||||
profile: TCertificateProfileWithConfigs | null;
|
||||
};
|
||||
|
||||
// Type for external CA renewal result
|
||||
type TExternalRenewalData = {
|
||||
isExternalCA: true;
|
||||
ca: TCertificateAuthorityWithAssociatedCa;
|
||||
profile: TCertificateProfileWithConfigs | null;
|
||||
originalCert: TCertificates;
|
||||
originalSignatureAlgorithm: CertSignatureAlgorithm;
|
||||
originalKeyAlgorithm: CertKeyAlgorithm;
|
||||
ttl: string;
|
||||
};
|
||||
|
||||
type TRenewalTransactionResult = TInternalRenewalData | TExternalRenewalData;
|
||||
|
||||
const renewCertificate = async ({
|
||||
certificateId,
|
||||
actor,
|
||||
@@ -1215,9 +1356,10 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
internal = false,
|
||||
removeRootsFromChain
|
||||
removeRootsFromChain,
|
||||
certificateRequestId
|
||||
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
|
||||
const renewalResult = await certificateDAL.transaction(async (tx) => {
|
||||
const renewalResult: TRenewalTransactionResult = await certificateDAL.transaction(async (tx) => {
|
||||
const originalCert = await certificateDAL.findById(certificateId, tx);
|
||||
if (!originalCert) {
|
||||
throw new NotFoundError({ message: "Certificate not found" });
|
||||
@@ -1229,14 +1371,30 @@ export const certificateV3ServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm;
|
||||
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm;
|
||||
// Validate and cast algorithms with fallbacks
|
||||
let originalSignatureAlgorithm = Object.values(CertSignatureAlgorithm).includes(
|
||||
originalCert.signatureAlgorithm as CertSignatureAlgorithm
|
||||
)
|
||||
? (originalCert.signatureAlgorithm as CertSignatureAlgorithm)
|
||||
: CertSignatureAlgorithm.RSA_SHA256;
|
||||
let originalKeyAlgorithm = Object.values(CertKeyAlgorithm).includes(originalCert.keyAlgorithm as CertKeyAlgorithm)
|
||||
? (originalCert.keyAlgorithm as CertKeyAlgorithm)
|
||||
: CertKeyAlgorithm.RSA_2048;
|
||||
|
||||
// For external CA certificates without stored algorithm info, extract from certificate
|
||||
if (!originalSignatureAlgorithm || !originalKeyAlgorithm) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
|
||||
});
|
||||
const isExternalCA = originalCert.caId && !originalCert.caId.startsWith("internal");
|
||||
|
||||
if (isExternalCA) {
|
||||
// For external CA certificates, we can extract algorithm info from the cert or use defaults
|
||||
originalSignatureAlgorithm = originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256;
|
||||
originalKeyAlgorithm = originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let profile = null;
|
||||
@@ -1303,7 +1461,10 @@ export const certificateV3ServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
validateCaSupport(ca, "direct certificate issuance");
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
if (caType === CaType.INTERNAL) {
|
||||
validateCaSupport(ca, "direct certificate issuance");
|
||||
}
|
||||
}
|
||||
|
||||
const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId;
|
||||
@@ -1334,37 +1495,7 @@ export const certificateV3ServiceFactory = ({
|
||||
parseExtendedKeyUsages(originalCert.extendedKeyUsages)
|
||||
),
|
||||
subjectAlternativeNames: originalCert.altNames
|
||||
? originalCert.altNames.split(",").map((san) => {
|
||||
const trimmed = san.trim();
|
||||
|
||||
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed);
|
||||
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed);
|
||||
if (isIpv4 || isIpv6) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.IP_ADDRESS,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.EMAIL,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.URI,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.DNS_NAME,
|
||||
value: trimmed
|
||||
};
|
||||
})
|
||||
? originalCert.altNames.split(",").map((san) => detectSanType(san.trim()))
|
||||
: [],
|
||||
validity: {
|
||||
ttl
|
||||
@@ -1408,41 +1539,64 @@ export const certificateV3ServiceFactory = ({
|
||||
throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" });
|
||||
}
|
||||
|
||||
validateAlgorithmCompatibility(ca, {
|
||||
algorithms: template?.algorithms
|
||||
} as { algorithms?: { signature?: string[] } });
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
|
||||
const caResult = await internalCaService.issueCertFromCa({
|
||||
caId: ca.id,
|
||||
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: originalCert.altNames || "",
|
||||
ttl,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
isFromProfile: true,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
internal: true,
|
||||
tx
|
||||
});
|
||||
|
||||
certificate = caResult.certificate;
|
||||
certificateChain = caResult.certificateChain;
|
||||
issuingCaCertificate = caResult.issuingCaCertificate;
|
||||
serialNumber = caResult.serialNumber;
|
||||
|
||||
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
|
||||
if (!foundCert) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
// Only validate algorithm compatibility for internal CAs
|
||||
if (caType === CaType.INTERNAL) {
|
||||
validateAlgorithmCompatibility(ca, {
|
||||
algorithms: template?.algorithms
|
||||
} as { algorithms?: { signature?: string[] } });
|
||||
}
|
||||
|
||||
if (caType === CaType.INTERNAL) {
|
||||
// Internal CA renewal - existing logic
|
||||
const caResult = await internalCaService.issueCertFromCa({
|
||||
caId: ca.id,
|
||||
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: originalCert.altNames || "",
|
||||
ttl,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
isFromProfile: true,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
internal: true,
|
||||
tx
|
||||
});
|
||||
|
||||
certificate = caResult.certificate;
|
||||
certificateChain = caResult.certificateChain;
|
||||
issuingCaCertificate = caResult.issuingCaCertificate;
|
||||
serialNumber = caResult.serialNumber;
|
||||
|
||||
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
|
||||
if (!foundCert) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
}
|
||||
newCert = foundCert;
|
||||
} else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
|
||||
// External CA renewal - mark for async processing outside transaction
|
||||
return {
|
||||
isExternalCA: true,
|
||||
ca,
|
||||
profile,
|
||||
originalCert,
|
||||
originalSignatureAlgorithm,
|
||||
originalKeyAlgorithm,
|
||||
ttl
|
||||
};
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `CA type ${String(caType)} does not support certificate renewal`
|
||||
});
|
||||
}
|
||||
newCert = foundCert;
|
||||
} else {
|
||||
// Self-signed certificate renewal
|
||||
const effectiveAlgorithms = getEffectiveAlgorithms(
|
||||
@@ -1522,6 +1676,48 @@ export const certificateV3ServiceFactory = ({
|
||||
};
|
||||
});
|
||||
|
||||
// Handle external CA renewals separately
|
||||
if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) {
|
||||
const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult;
|
||||
|
||||
const renewalOrderId = randomUUID();
|
||||
const altNamesArray = originalCert.altNames
|
||||
? originalCert.altNames.split(",").map((san: string) => san.trim())
|
||||
: [];
|
||||
|
||||
await certificateIssuanceQueue.queueCertificateIssuance({
|
||||
certificateId: renewalOrderId,
|
||||
profileId: profile?.id || "",
|
||||
caId: ca.id,
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: altNamesArray,
|
||||
ttl,
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
keyUsages: convertEnumsToStringArray(parseKeyUsages(originalCert.keyUsages)),
|
||||
extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)),
|
||||
isRenewal: true,
|
||||
originalCertificateId: certificateId,
|
||||
certificateRequestId
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: "", // External CA renewal is async
|
||||
certificateChain: "",
|
||||
issuingCaCertificate: "",
|
||||
serialNumber: "",
|
||||
certificateId: renewalOrderId,
|
||||
projectId: originalCert.projectId,
|
||||
profileName: profile?.slug || "External CA Profile",
|
||||
commonName: originalCert.commonName || ""
|
||||
};
|
||||
}
|
||||
|
||||
// Type check to ensure we have internal CA renewal result
|
||||
if ("isExternalCA" in renewalResult) {
|
||||
throw new BadRequestError({ message: "External CA renewals should be handled asynchronously" });
|
||||
}
|
||||
|
||||
await triggerAutoSyncForCertificate(renewalResult.newCert.id, {
|
||||
certificateSyncDAL,
|
||||
pkiSyncDAL,
|
||||
|
||||
@@ -58,8 +58,10 @@ export type TOrderCertificateFromProfileDTO = {
|
||||
notAfter?: Date;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
template?: string;
|
||||
};
|
||||
removeRootsFromChain?: boolean;
|
||||
certificateRequestId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCertificateFromProfileResponse = {
|
||||
@@ -105,6 +107,7 @@ export type TCertificateOrderResponse = {
|
||||
export type TRenewCertificateDTO = {
|
||||
certificateId: string;
|
||||
removeRootsFromChain?: boolean;
|
||||
certificateRequestId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateRenewalConfigDTO = {
|
||||
|
||||
@@ -22,14 +22,25 @@ export type TCertificateProfile = {
|
||||
apiConfigId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
certificateAuthority?: {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
status: string;
|
||||
name: string;
|
||||
isExternal?: boolean;
|
||||
externalType?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCertificateProfileWithDetails = TCertificateProfile & {
|
||||
certificateAuthority?: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectId?: string;
|
||||
status: string;
|
||||
name: string;
|
||||
isExternal?: boolean;
|
||||
externalType?: string | null;
|
||||
};
|
||||
certificateTemplate?: {
|
||||
id: string;
|
||||
@@ -72,6 +83,7 @@ export type TCreateCertificateProfileDTO = {
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TUpdateCertificateProfileDTO = {
|
||||
@@ -90,6 +102,7 @@ export type TUpdateCertificateProfileDTO = {
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TDeleteCertificateProfileDTO = {
|
||||
|
||||
@@ -7,4 +7,4 @@ export {
|
||||
useRevokeCert,
|
||||
useUpdateRenewalConfig
|
||||
} from "./mutations";
|
||||
export { useGetCert, useGetCertBody } from "./queries";
|
||||
export { useGetCert, useGetCertBody, useGetCertificateRequest } from "./queries";
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
TRenewCertificateDTO,
|
||||
TRenewCertificateResponse,
|
||||
TRevokeCertDTO,
|
||||
TUnifiedCertificateIssuanceDTO,
|
||||
TUnifiedCertificateIssuanceResponse,
|
||||
TUpdateRenewalConfigDTO
|
||||
} from "./types";
|
||||
|
||||
@@ -185,3 +187,34 @@ export const useDownloadCertPkcs12 = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnifiedCertificateIssuance = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TUnifiedCertificateIssuanceResponse, object, TUnifiedCertificateIssuanceDTO>({
|
||||
mutationFn: async (body) => {
|
||||
const { projectSlug, ...requestData } = body;
|
||||
const { data } = await apiRequest.post<TUnifiedCertificateIssuanceResponse>(
|
||||
"/api/v3/pki/certificates",
|
||||
requestData,
|
||||
{
|
||||
params: { projectSlug }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["certificate-profiles", "list"]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.allProjectCertificates()
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TCertificate } from "./types";
|
||||
import { TCertificate, TCertificateRequestDetails } from "./types";
|
||||
|
||||
export const certKeys = {
|
||||
getCertById: (serialNumber: string) => [{ serialNumber }, "cert"],
|
||||
getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"],
|
||||
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"]
|
||||
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"],
|
||||
getCertificateRequest: (requestId: string, projectSlug: string) => [
|
||||
{ requestId, projectSlug },
|
||||
"certificateRequest"
|
||||
]
|
||||
};
|
||||
|
||||
export const useGetCert = (serialNumber: string) => {
|
||||
@@ -55,3 +59,23 @@ export const useGetCertBundle = (serialNumber: string) => {
|
||||
enabled: Boolean(serialNumber)
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCertificateRequest = (requestId: string, projectSlug: string) => {
|
||||
return useQuery({
|
||||
queryKey: certKeys.getCertificateRequest(requestId, projectSlug),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TCertificateRequestDetails>(
|
||||
`/api/v3/pki/certificates/requests/${requestId}`,
|
||||
{
|
||||
params: { projectSlug }
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: Boolean(requestId) && Boolean(projectSlug),
|
||||
refetchInterval: (query) => {
|
||||
// Only refetch if status is pending
|
||||
return query.state.data?.status === "pending" ? 5000 : false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export type TRenewCertificateResponse = {
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
projectId: string;
|
||||
certificateRequestId?: string;
|
||||
};
|
||||
|
||||
export type TUpdateRenewalConfigDTO = {
|
||||
@@ -79,3 +80,51 @@ export type TDownloadPkcs12DTO = {
|
||||
password: string;
|
||||
alias: string;
|
||||
};
|
||||
|
||||
export type TUnifiedCertificateIssuanceDTO = {
|
||||
projectSlug: string;
|
||||
projectId: string;
|
||||
profileId?: string;
|
||||
caId?: string;
|
||||
csr?: string;
|
||||
commonName?: string;
|
||||
altNames?: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
keyAlgorithm?: string;
|
||||
signatureAlgorithm?: string;
|
||||
ttl?: string;
|
||||
friendlyName?: string;
|
||||
pkiCollectionId?: string;
|
||||
issuerType?: string;
|
||||
};
|
||||
|
||||
export type TUnifiedCertificateResponse = {
|
||||
certificate: string;
|
||||
issuingCaCertificate: string;
|
||||
certificateChain: string;
|
||||
privateKey?: string;
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TCertificateRequestResponse = {
|
||||
certificateRequestId: string;
|
||||
status: "pending" | "issued" | "failed";
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TUnifiedCertificateIssuanceResponse =
|
||||
| TUnifiedCertificateResponse
|
||||
| TCertificateRequestResponse;
|
||||
|
||||
export type TCertificateRequestDetails = {
|
||||
status: "pending" | "issued" | "failed";
|
||||
certificate: TCertificate | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -20,9 +20,9 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useGetCert } from "@app/hooks/api";
|
||||
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
|
||||
import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
|
||||
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
|
||||
import { useUnifiedCertificateIssuance } from "@app/hooks/api/certificates/mutations";
|
||||
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
|
||||
@@ -103,10 +103,10 @@ type Props = {
|
||||
};
|
||||
|
||||
type TCertificateDetails = {
|
||||
serialNumber: string;
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
privateKey: string;
|
||||
serialNumber?: string;
|
||||
certificate?: string;
|
||||
certificateChain?: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
|
||||
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
|
||||
@@ -122,12 +122,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
|
||||
const { data: profilesData } = useListCertificateProfiles({
|
||||
projectId: currentProject?.id || "",
|
||||
enrollmentType: EnrollmentType.API
|
||||
enrollmentType: EnrollmentType.API,
|
||||
includeConfigs: true
|
||||
});
|
||||
|
||||
const { mutateAsync: createCertificate } = useCreateCertificateV3({
|
||||
projectId: currentProject?.id
|
||||
});
|
||||
const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance();
|
||||
|
||||
const formResolver = useMemo(() => {
|
||||
return zodResolver(createSchema(shouldShowSubjectSection));
|
||||
@@ -243,7 +242,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
}: FormData) => {
|
||||
if (!currentProject?.slug) {
|
||||
if (!currentProject?.slug || !currentProject?.id) {
|
||||
createNotification({
|
||||
text: "Project not found. Please refresh and try again.",
|
||||
type: "error"
|
||||
@@ -275,44 +274,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
}
|
||||
}
|
||||
|
||||
const certificateRequest: any = {
|
||||
profileId: formProfileId,
|
||||
projectSlug: currentProject.slug,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
|
||||
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
|
||||
};
|
||||
try {
|
||||
// Prepare unified request
|
||||
const request: any = {
|
||||
profileId: formProfileId,
|
||||
projectSlug: currentProject.slug,
|
||||
projectId: currentProject.id,
|
||||
ttl,
|
||||
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
|
||||
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
|
||||
};
|
||||
|
||||
if (constraints.shouldShowSubjectSection && commonName) {
|
||||
certificateRequest.commonName = commonName;
|
||||
}
|
||||
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
|
||||
const formattedSans = formatSubjectAltNames(subjectAltNames);
|
||||
if (formattedSans && formattedSans.length > 0) {
|
||||
certificateRequest.altNames = formattedSans;
|
||||
if (constraints.shouldShowSubjectSection && commonName) {
|
||||
request.commonName = commonName;
|
||||
}
|
||||
|
||||
if (signatureAlgorithm) {
|
||||
request.signatureAlgorithm = signatureAlgorithm;
|
||||
}
|
||||
|
||||
if (keyAlgorithm) {
|
||||
request.keyAlgorithm = keyAlgorithm;
|
||||
}
|
||||
|
||||
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
|
||||
const formattedSans = formatSubjectAltNames(subjectAltNames);
|
||||
if (formattedSans && formattedSans.length > 0) {
|
||||
request.altNames = formattedSans;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await issueCertificate(request);
|
||||
|
||||
// Handle certificate issuance response
|
||||
if ("certificate" in response) {
|
||||
// Immediate certificate issuance
|
||||
setCertificateDetails({
|
||||
serialNumber: response.serialNumber,
|
||||
certificate: response.certificate,
|
||||
certificateChain: response.certificateChain,
|
||||
privateKey: response.privateKey
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created certificate",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
// Certificate request - async processing
|
||||
createNotification({
|
||||
text: `Certificate request submitted successfully. This may take a few minutes to process. Request ID: ${response.certificateRequestId}`,
|
||||
type: "success"
|
||||
});
|
||||
handlePopUpToggle("issueCertificate", false);
|
||||
}
|
||||
} catch (error) {
|
||||
createNotification({
|
||||
text: `Failed to request certificate: ${(error as Error)?.message || "Unknown error"}`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
const { serialNumber, certificate, certificateChain, privateKey } =
|
||||
await createCertificate(certificateRequest);
|
||||
|
||||
setCertificateDetails({
|
||||
serialNumber,
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created certificate",
|
||||
type: "success"
|
||||
});
|
||||
},
|
||||
[
|
||||
currentProject?.slug,
|
||||
createCertificate,
|
||||
issueCertificate,
|
||||
constraints.shouldShowSubjectSection,
|
||||
constraints.shouldShowSanSection
|
||||
]
|
||||
@@ -321,13 +346,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
const getModalTitle = () => {
|
||||
if (certificateDetails) return "Certificate Created Successfully";
|
||||
if (cert) return "Certificate Details";
|
||||
return "Issue New Certificate";
|
||||
return "Request New Certificate";
|
||||
};
|
||||
|
||||
const getModalSubTitle = () => {
|
||||
if (certificateDetails) return "Certificate has been successfully created and is ready for use";
|
||||
if (cert) return "View certificate information";
|
||||
return "Issue a new certificate using a certificate profile";
|
||||
return "Request a new certificate using a certificate profile";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -343,10 +368,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
|
||||
{certificateDetails && (
|
||||
<CertificateContent
|
||||
serialNumber={certificateDetails.serialNumber}
|
||||
certificate={certificateDetails.certificate}
|
||||
certificateChain={certificateDetails.certificateChain}
|
||||
privateKey={certificateDetails.privateKey}
|
||||
serialNumber={certificateDetails.serialNumber!}
|
||||
certificate={certificateDetails.certificate!}
|
||||
certificateChain={certificateDetails.certificateChain!}
|
||||
privateKey={certificateDetails.privateKey!}
|
||||
/>
|
||||
)}
|
||||
{cert && (
|
||||
@@ -498,7 +523,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)}
|
||||
>
|
||||
{cert ? "Update" : "Issue Certificate"}
|
||||
{cert ? "Update" : "Request Certificate"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
|
||||
@@ -20,12 +20,16 @@ export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) =>
|
||||
const onRenewConfirm = async () => {
|
||||
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
|
||||
|
||||
await renewCertificate({
|
||||
const result = await renewCertificate({
|
||||
certificateId
|
||||
});
|
||||
|
||||
const notificationText = result.certificateRequestId
|
||||
? `Certificate renewal initiated successfully. Request ID: ${result.certificateRequestId}`
|
||||
: "Certificate renewed successfully";
|
||||
|
||||
createNotification({
|
||||
text: "Certificate renewed successfully",
|
||||
text: notificationText,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect } from "react";
|
||||
import { faCheck, faExclamationTriangle, faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useProject } from "@app/context";
|
||||
import { useGetCertificateRequest } from "@app/hooks/api/certificates";
|
||||
|
||||
type CertificateInfo = {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
notAfter: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
requestId: string;
|
||||
onCertificateIssued?: (certificate: CertificateInfo) => void;
|
||||
};
|
||||
|
||||
export const CertificateRequestTracker = ({ requestId, onCertificateIssued }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
|
||||
const { data: requestData, isLoading } = useGetCertificateRequest(
|
||||
requestId,
|
||||
currentProject?.slug || ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestData?.status === "issued" && requestData.certificate && onCertificateIssued) {
|
||||
onCertificateIssued(requestData.certificate);
|
||||
}
|
||||
}, [requestData, onCertificateIssued]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin text-primary" />
|
||||
<span className="text-sm text-mineshaft-400">Loading request status...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (requestData?.status) {
|
||||
case "pending":
|
||||
return <FontAwesomeIcon icon={faSpinner} className="animate-spin text-yellow-500" />;
|
||||
case "issued":
|
||||
return <FontAwesomeIcon icon={faCheck} className="text-green-500" />;
|
||||
case "failed":
|
||||
return <FontAwesomeIcon icon={faExclamationTriangle} className="text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (requestData?.status) {
|
||||
case "pending":
|
||||
return "Certificate request is being processed...";
|
||||
case "issued":
|
||||
return "Certificate has been issued successfully!";
|
||||
case "failed":
|
||||
return `Certificate request failed: ${requestData.errorMessage || "Unknown error"}`;
|
||||
default:
|
||||
return "Unknown status";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium text-mineshaft-300">Request ID: {requestId}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-mineshaft-400">
|
||||
Status: <span className="font-medium capitalize">{requestData?.status || "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-mineshaft-400">{getStatusMessage()}</div>
|
||||
|
||||
{requestData?.status === "issued" && requestData.certificate && (
|
||||
<div className="mt-4 rounded-md border border-green-500/30 bg-green-900/20 p-3">
|
||||
<div className="text-sm text-green-400">
|
||||
<strong>Certificate Details:</strong>
|
||||
<br />
|
||||
Serial Number: {requestData.certificate.serialNumber}
|
||||
<br />
|
||||
Common Name: {requestData.certificate.commonName}
|
||||
<br />
|
||||
Valid Until: {new Date(requestData.certificate.notAfter).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requestData?.status === "failed" && requestData.errorMessage && (
|
||||
<div className="mt-4 rounded-md border border-red-500/30 bg-red-900/20 p-3">
|
||||
<div className="text-sm text-red-400">
|
||||
<strong>Error Details:</strong>
|
||||
<br />
|
||||
{requestData.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -119,7 +119,7 @@ export const CertificatesSection = () => {
|
||||
onClick={() => handlePopUpOpen("issueCertificate")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Issue
|
||||
Request
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useProject, useSubscription } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import { CaType } from "@app/hooks/api/ca/enums";
|
||||
import { useGetAzureAdcsTemplates, useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import {
|
||||
EnrollmentType,
|
||||
IssuerType,
|
||||
@@ -77,7 +79,12 @@ const createSchema = z
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
acmeConfig: z.object({}).optional(),
|
||||
externalConfigs: z
|
||||
.object({
|
||||
template: z.string().min(1, "Azure ADCS template is required")
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -212,7 +219,12 @@ const editSchema = z
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
acmeConfig: z.object({}).optional(),
|
||||
externalConfigs: z
|
||||
.object({
|
||||
template: z.string().optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -339,7 +351,7 @@ export const CreateProfileModal = ({
|
||||
const { currentProject } = useProject();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
|
||||
const { data: allCaData } = useListCasByProjectId(currentProject?.id || "");
|
||||
const { data: templateData } = useListCertificateTemplatesV2({
|
||||
projectId: currentProject?.id || "",
|
||||
limit: 100,
|
||||
@@ -351,9 +363,23 @@ export const CreateProfileModal = ({
|
||||
|
||||
const isEdit = mode === "edit" && profile;
|
||||
|
||||
const certificateAuthorities = caData || [];
|
||||
const certificateAuthorities = (allCaData || []).map((ca) => ({
|
||||
...ca,
|
||||
groupType: ca.type === "internal" ? "internal" : "external"
|
||||
}));
|
||||
const certificateTemplates = templateData?.certificateTemplates || [];
|
||||
|
||||
const getGroupHeaderLabel = (groupType: "internal" | "external") => {
|
||||
switch (groupType) {
|
||||
case "internal":
|
||||
return "Internal CAs";
|
||||
case "external":
|
||||
return "External CAs";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
|
||||
resolver: zodResolver(isEdit ? editSchema : createSchema),
|
||||
defaultValues: isEdit
|
||||
@@ -380,7 +406,17 @@ export const CreateProfileModal = ({
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined,
|
||||
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
|
||||
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
|
||||
externalConfigs: profile.externalConfigs
|
||||
? {
|
||||
template:
|
||||
typeof profile.externalConfigs === "object" &&
|
||||
profile.externalConfigs !== null &&
|
||||
typeof profile.externalConfigs.template === "string"
|
||||
? profile.externalConfigs.template
|
||||
: ""
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: {
|
||||
slug: "",
|
||||
@@ -393,15 +429,27 @@ export const CreateProfileModal = ({
|
||||
autoRenew: false,
|
||||
renewBeforeDays: 30
|
||||
},
|
||||
acmeConfig: {}
|
||||
acmeConfig: {},
|
||||
externalConfigs: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const watchedEnrollmentType = watch("enrollmentType");
|
||||
const watchedIssuerType = watch("issuerType");
|
||||
const watchedCertificateAuthorityId = watch("certificateAuthorityId");
|
||||
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
|
||||
const watchedAutoRenew = watch("apiConfig.autoRenew");
|
||||
|
||||
// Get the selected CA to check if it's Azure ADCS
|
||||
const selectedCa = certificateAuthorities.find((ca) => ca.id === watchedCertificateAuthorityId);
|
||||
const isAzureAdcsCa = selectedCa?.type === CaType.AZURE_AD_CS;
|
||||
|
||||
// Fetch Azure ADCS templates if needed
|
||||
const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({
|
||||
caId: watchedCertificateAuthorityId || "",
|
||||
projectId: currentProject?.id || ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && profile) {
|
||||
reset({
|
||||
@@ -427,10 +475,38 @@ export const CreateProfileModal = ({
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined,
|
||||
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
|
||||
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
|
||||
externalConfigs: profile.externalConfigs
|
||||
? {
|
||||
template:
|
||||
typeof profile.externalConfigs === "object" &&
|
||||
profile.externalConfigs !== null &&
|
||||
typeof profile.externalConfigs.template === "string"
|
||||
? profile.externalConfigs.template
|
||||
: ""
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
}
|
||||
}, [isEdit, profile, reset]);
|
||||
}, [isEdit, profile, reset, allCaData]);
|
||||
|
||||
// Additional effect to reset external configs when Azure ADCS templates are loaded
|
||||
useEffect(() => {
|
||||
if (
|
||||
isEdit &&
|
||||
profile &&
|
||||
isAzureAdcsCa &&
|
||||
azureAdcsTemplatesData?.templates &&
|
||||
profile.externalConfigs &&
|
||||
typeof profile.externalConfigs === "object" &&
|
||||
profile.externalConfigs !== null &&
|
||||
typeof profile.externalConfigs.template === "string"
|
||||
) {
|
||||
// Re-set the external configs to ensure the template value is properly set
|
||||
// after the Azure ADCS templates have been loaded
|
||||
setValue("externalConfigs.template", profile.externalConfigs.template);
|
||||
}
|
||||
}, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]);
|
||||
|
||||
const onFormSubmit = async (data: FormData) => {
|
||||
if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
|
||||
@@ -444,6 +520,18 @@ export const CreateProfileModal = ({
|
||||
|
||||
if (!currentProject?.id && !isEdit) return;
|
||||
|
||||
// Validate Azure ADCS template requirement
|
||||
if (
|
||||
isAzureAdcsCa &&
|
||||
(!data.externalConfigs?.template || data.externalConfigs.template.trim() === "")
|
||||
) {
|
||||
createNotification({
|
||||
text: "Azure ADCS Certificate Authority requires a template to be specified",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const updateData: TUpdateCertificateProfileDTO = {
|
||||
profileId: profile.id,
|
||||
@@ -460,6 +548,11 @@ export const CreateProfileModal = ({
|
||||
updateData.acmeConfig = data.acmeConfig;
|
||||
}
|
||||
|
||||
// Add external configs if present
|
||||
if (data.externalConfigs) {
|
||||
updateData.externalConfigs = data.externalConfigs;
|
||||
}
|
||||
|
||||
await updateProfile.mutateAsync(updateData);
|
||||
} else {
|
||||
if (!currentProject?.id) {
|
||||
@@ -491,6 +584,11 @@ export const CreateProfileModal = ({
|
||||
createData.acmeConfig = data.acmeConfig;
|
||||
}
|
||||
|
||||
// Add external configs if present
|
||||
if (data.externalConfigs) {
|
||||
createData.externalConfigs = data.externalConfigs;
|
||||
}
|
||||
|
||||
await createProfile.mutateAsync(createData);
|
||||
}
|
||||
|
||||
@@ -587,30 +685,82 @@ export const CreateProfileModal = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="certificateAuthorityId"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Issuing CA"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
{...field}
|
||||
value={value || undefined}
|
||||
onValueChange={onChange}
|
||||
<FilterableSelect
|
||||
value={certificateAuthorities.find((ca) => ca.id === value) || null}
|
||||
onChange={(selectedCaValue) => {
|
||||
if (Array.isArray(selectedCaValue)) {
|
||||
onChange(selectedCaValue[0]?.id || "");
|
||||
} else if (
|
||||
selectedCaValue &&
|
||||
typeof selectedCaValue === "object" &&
|
||||
"id" in selectedCaValue
|
||||
) {
|
||||
onChange(selectedCaValue.id || "");
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
}}
|
||||
getOptionLabel={(ca) =>
|
||||
ca.type === "internal" && ca.configuration.friendlyName
|
||||
? ca.configuration.friendlyName
|
||||
: ca.name
|
||||
}
|
||||
getOptionValue={(ca) => ca.id}
|
||||
options={certificateAuthorities}
|
||||
groupBy="groupType"
|
||||
getGroupHeaderLabel={getGroupHeaderLabel}
|
||||
placeholder="Select a certificate authority"
|
||||
className="w-full"
|
||||
position="popper"
|
||||
isDisabled={Boolean(isEdit)}
|
||||
>
|
||||
{certificateAuthorities.map((ca) => (
|
||||
<SelectItem key={ca.id} value={ca.id}>
|
||||
{ca.type === "internal" && ca.configuration.friendlyName
|
||||
? ca.configuration.friendlyName
|
||||
: ca.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Azure ADCS Template Selection */}
|
||||
{isAzureAdcsCa && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="externalConfigs.template"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Azure ADCS Template"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
value={
|
||||
azureAdcsTemplatesData?.templates.find((template) => template.id === value) ||
|
||||
null
|
||||
}
|
||||
onChange={(selectedTemplate) => {
|
||||
if (Array.isArray(selectedTemplate)) {
|
||||
onChange(selectedTemplate[0]?.id || "");
|
||||
} else if (
|
||||
selectedTemplate &&
|
||||
typeof selectedTemplate === "object" &&
|
||||
"id" in selectedTemplate
|
||||
) {
|
||||
onChange(selectedTemplate.id || "");
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
}}
|
||||
getOptionLabel={(template) => template.name}
|
||||
getOptionValue={(template) => template.id}
|
||||
options={azureAdcsTemplatesData?.templates || []}
|
||||
placeholder="Select an Azure ADCS certificate template"
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
@@ -49,7 +50,9 @@ export const ProfileRow = ({
|
||||
}: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { data: caData } = useGetCaById(profile.caId ?? "");
|
||||
const { data: caData } = useGetCaById(
|
||||
profile.certificateAuthority?.isExternal ? "" : (profile.caId ?? "")
|
||||
);
|
||||
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const);
|
||||
|
||||
@@ -120,11 +123,18 @@ export const ProfileRow = ({
|
||||
</Td>
|
||||
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
|
||||
<Td className="text-start">
|
||||
<span className="text-sm text-mineshaft-300">
|
||||
{profile.issuerType === IssuerType.SELF_SIGNED
|
||||
? "Self-signed"
|
||||
: caData?.friendlyName || caData?.commonName || profile.caId}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-mineshaft-300">
|
||||
{profile.issuerType === IssuerType.SELF_SIGNED
|
||||
? "Self-signed"
|
||||
: profile.certificateAuthority?.isExternal
|
||||
? profile.certificateAuthority.name
|
||||
: caData?.friendlyName ||
|
||||
caData?.commonName ||
|
||||
profile.certificateAuthority?.name ||
|
||||
profile.caId}
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-300">
|
||||
@@ -177,7 +187,7 @@ export const ProfileRow = ({
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faPlus} className="w-3" />}
|
||||
>
|
||||
Issue Certificate
|
||||
Request Certificate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDeleteProfile && (
|
||||
|
||||
Reference in New Issue
Block a user