Merge pull request #4686 from Infisical/pki-revamp-v3

PKI Revamp: Move to Template Policies and Certificate Profiles
This commit is contained in:
carlosmonastyrski
2025-10-20 16:17:08 -03:00
committed by GitHub
134 changed files with 17164 additions and 332 deletions

View File

@@ -61,7 +61,11 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-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";
import { TCmekServiceFactory } from "@app/services/cmek/cmek-service";
import { TConvertorServiceFactory } from "@app/services/convertor/convertor-service";
import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
@@ -262,7 +266,10 @@ declare module "fastify" {
auditLog: TAuditLogServiceFactory;
auditLogStream: TAuditLogStreamServiceFactory;
certificate: TCertificateServiceFactory;
certificateV3: TCertificateV3ServiceFactory;
certificateTemplate: TCertificateTemplateServiceFactory;
certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
certificateProfile: TCertificateProfileServiceFactory;
sshCertificateAuthority: TSshCertificateAuthorityServiceFactory;
sshCertificateTemplate: TSshCertificateTemplateServiceFactory;
sshHost: TSshHostServiceFactory;
@@ -270,6 +277,7 @@ declare module "fastify" {
certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
certificateEstV3: TCertificateEstV3ServiceFactory;
pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory;
pkiSync: TPkiSyncServiceFactory;

View File

@@ -266,12 +266,24 @@ import {
TPkiAlerts,
TPkiAlertsInsert,
TPkiAlertsUpdate,
TPkiApiEnrollmentConfigs,
TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate,
TPkiCertificateProfiles,
TPkiCertificateProfilesInsert,
TPkiCertificateProfilesUpdate,
TPkiCertificateTemplatesV2,
TPkiCertificateTemplatesV2Insert,
TPkiCertificateTemplatesV2Update,
TPkiCollectionItems,
TPkiCollectionItemsInsert,
TPkiCollectionItemsUpdate,
TPkiCollections,
TPkiCollectionsInsert,
TPkiCollectionsUpdate,
TPkiEstEnrollmentConfigs,
TPkiEstEnrollmentConfigsInsert,
TPkiEstEnrollmentConfigsUpdate,
TPkiSubscribers,
TPkiSubscribersInsert,
TPkiSubscribersUpdate,
@@ -674,6 +686,26 @@ declare module "knex/types/tables" {
TCertificateTemplatesInsert,
TCertificateTemplatesUpdate
>;
[TableName.PkiCertificateTemplateV2]: KnexOriginal.CompositeTableType<
TPkiCertificateTemplatesV2,
TPkiCertificateTemplatesV2Insert,
TPkiCertificateTemplatesV2Update
>;
[TableName.PkiCertificateProfile]: KnexOriginal.CompositeTableType<
TPkiCertificateProfiles,
TPkiCertificateProfilesInsert,
TPkiCertificateProfilesUpdate
>;
[TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType<
TPkiEstEnrollmentConfigs,
TPkiEstEnrollmentConfigsInsert,
TPkiEstEnrollmentConfigsUpdate
>;
[TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType<
TPkiApiEnrollmentConfigs,
TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate
>;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs,
TCertificateTemplateEstConfigsInsert,

View File

@@ -0,0 +1,117 @@
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.PkiCertificateTemplateV2))) {
await knex.schema.createTable(TableName.PkiCertificateTemplateV2, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project);
t.string("name").notNullable();
t.string("description");
t.jsonb("subject");
t.jsonb("sans");
t.jsonb("keyUsages");
t.jsonb("extendedKeyUsages");
t.jsonb("algorithms");
t.jsonb("validity");
t.timestamps(true, true, true);
t.unique(["name", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2);
}
if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) {
await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("disableBootstrapCaValidation").defaultTo(false);
t.text("hashedPassphrase").notNullable();
t.binary("encryptedCaChain");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
}
if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) {
await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.boolean("autoRenew").defaultTo(false);
t.integer("autoRenewDays");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
}
if (!(await knex.schema.hasTable(TableName.PkiCertificateProfile))) {
await knex.schema.createTable(TableName.PkiCertificateProfile, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.uuid("caId").notNullable();
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority);
t.uuid("certificateTemplateId").notNullable();
t.foreign("certificateTemplateId").references("id").inTable(TableName.PkiCertificateTemplateV2);
t.string("slug").notNullable();
t.string("description");
t.string("enrollmentType").notNullable().checkIn(["api", "est"]);
t.uuid("estConfigId");
t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL");
t.uuid("apiConfigId");
t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL");
t.timestamps(true, true, true);
t.unique(["slug", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.PkiCertificateProfile);
}
if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.uuid("profileId");
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL");
t.index("profileId");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.dropForeign(["profileId"]);
t.dropIndex("profileId");
t.dropColumn("profileId");
});
}
await knex.schema.dropTableIfExists(TableName.PkiCertificateProfile);
await dropOnUpdateTrigger(knex, TableName.PkiCertificateProfile);
await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig);
await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig);
await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig);
await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig);
await knex.schema.dropTableIfExists(TableName.PkiCertificateTemplateV2);
await dropOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2);
}

View File

@@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional(),
projectId: z.string(),
pkiSubscriberId: z.string().uuid().nullable().optional()
pkiSubscriberId: z.string().uuid().nullable().optional(),
profileId: z.string().uuid().nullable().optional()
});
export type TCertificates = z.infer<typeof CertificatesSchema>;

View File

@@ -92,8 +92,12 @@ export * from "./pam-folders";
export * from "./pam-resources";
export * from "./pam-sessions";
export * from "./pki-alerts";
export * from "./pki-api-enrollment-configs";
export * from "./pki-certificate-profiles";
export * from "./pki-certificate-templates-v2";
export * from "./pki-collection-items";
export * from "./pki-collections";
export * from "./pki-est-enrollment-configs";
export * from "./pki-subscribers";
export * from "./pki-syncs";
export * from "./project-bots";

View File

@@ -23,6 +23,10 @@ export enum TableName {
CertificateBody = "certificate_bodies",
CertificateSecret = "certificate_secrets",
CertificateTemplate = "certificate_templates",
PkiCertificateTemplateV2 = "pki_certificate_templates_v2",
PkiCertificateProfile = "pki_certificate_profiles",
PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts",
PkiCollection = "pki_collections",

View File

@@ -0,0 +1,22 @@
// 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 PkiApiEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
autoRenew: z.boolean().default(false).nullable().optional(),
autoRenewDays: z.number().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiApiEnrollmentConfigs = z.infer<typeof PkiApiEnrollmentConfigsSchema>;
export type TPkiApiEnrollmentConfigsInsert = Omit<z.input<typeof PkiApiEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TPkiApiEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiApiEnrollmentConfigsSchema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,28 @@
// 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 PkiCertificateProfilesSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
slug: z.string(),
description: z.string().nullable().optional(),
enrollmentType: z.string(),
estConfigId: z.string().uuid().nullable().optional(),
apiConfigId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
export type TPkiCertificateProfilesInsert = Omit<z.input<typeof PkiCertificateProfilesSchema>, TImmutableDBKeys>;
export type TPkiCertificateProfilesUpdate = Partial<
Omit<z.input<typeof PkiCertificateProfilesSchema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,29 @@
// 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 PkiCertificateTemplatesV2Schema = z.object({
id: z.string().uuid(),
projectId: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
subject: z.unknown().nullable().optional(),
sans: z.unknown().nullable().optional(),
keyUsages: z.unknown().nullable().optional(),
extendedKeyUsages: z.unknown().nullable().optional(),
algorithms: z.unknown().nullable().optional(),
validity: z.unknown().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiCertificateTemplatesV2 = z.infer<typeof PkiCertificateTemplatesV2Schema>;
export type TPkiCertificateTemplatesV2Insert = Omit<z.input<typeof PkiCertificateTemplatesV2Schema>, TImmutableDBKeys>;
export type TPkiCertificateTemplatesV2Update = Partial<
Omit<z.input<typeof PkiCertificateTemplatesV2Schema>, TImmutableDBKeys>
>;

View File

@@ -0,0 +1,25 @@
// 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 { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const PkiEstEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(),
hashedPassphrase: z.string(),
encryptedCaChain: zodBuffer.nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiEstEnrollmentConfigs = z.infer<typeof PkiEstEnrollmentConfigsSchema>;
export type TPkiEstEnrollmentConfigsInsert = Omit<z.input<typeof PkiEstEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TPkiEstEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiEstEnrollmentConfigsSchema>, TImmutableDBKeys>
>;

View File

@@ -8,6 +8,35 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
export const registerCertificateEstRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => {
try {
// Try to find as profile first using internal access
await server.services.certificateProfile.getEstConfigurationByProfile({
profileId: identifier,
isInternal: true
});
return "profile";
} catch (profileError) {
try {
await server.services.certificateTemplate.getEstConfiguration({
isInternal: true,
certificateTemplateId: identifier
});
return "template";
} catch (templateError) {
server.log.debug(
{
identifier,
profileError: profileError instanceof Error ? profileError.message : "Unknown error",
templateError: templateError instanceof Error ? templateError.message : "Unknown error"
},
"EST identifier not found as profile or template"
);
return null;
}
}
};
// add support for CSR bodies
server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => {
try {
@@ -59,11 +88,29 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
return;
}
const certificateTemplateId = urlFragments.slice(-2)[0];
const estConfig = await server.services.certificateTemplate.getEstConfiguration({
isInternal: true,
certificateTemplateId
});
const identifier = urlFragments.slice(-2)[0];
const identifierType = await getIdentifierType(identifier);
if (!identifierType) {
res.raw.statusCode = 404;
res.raw.setHeader("Content-Type", "text/plain");
res.raw.write("Certificate template or profile not found");
res.raw.flushHeaders();
return;
}
let estConfig;
if (identifierType === "profile") {
estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({
profileId: identifier,
isInternal: true
});
} else {
estConfig = await server.services.certificateTemplate.getEstConfiguration({
isInternal: true,
certificateTemplateId: identifier
});
}
if (!estConfig.isEnabled) {
throw new BadRequestError({
@@ -95,14 +142,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "POST",
url: "/:certificateTemplateId/simpleenroll",
url: "/:identifier/simpleenroll",
config: {
rateLimit: writeLimit
},
schema: {
body: z.string().min(1),
params: z.object({
certificateTemplateId: z.string().min(1)
identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -112,9 +159,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
const { identifier } = req.params;
const identifierType = await getIdentifierType(identifier);
if (!identifierType) {
throw new BadRequestError({ message: "Certificate template or profile not found" });
}
if (identifierType === "profile") {
return server.services.certificateEstV3.simpleEnrollByProfile({
csr: req.body,
profileId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
return server.services.certificateEst.simpleEnroll({
csr: req.body,
certificateTemplateId: req.params.certificateTemplateId,
certificateTemplateId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
@@ -122,14 +183,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "POST",
url: "/:certificateTemplateId/simplereenroll",
url: "/:identifier/simplereenroll",
config: {
rateLimit: writeLimit
},
schema: {
body: z.string().min(1),
params: z.object({
certificateTemplateId: z.string().min(1)
identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -139,9 +200,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
const { identifier } = req.params;
const identifierType = await getIdentifierType(identifier);
if (!identifierType) {
throw new BadRequestError({ message: "Certificate template or profile not found" });
}
if (identifierType === "profile") {
return server.services.certificateEstV3.simpleReenrollByProfile({
csr: req.body,
profileId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
return server.services.certificateEst.simpleReenroll({
csr: req.body,
certificateTemplateId: req.params.certificateTemplateId,
certificateTemplateId: identifier,
sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string
});
}
@@ -149,13 +224,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
server.route({
method: "GET",
url: "/:certificateTemplateId/cacerts",
url: "/:identifier/cacerts",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
certificateTemplateId: z.string().min(1)
identifier: z.string().min(1)
}),
response: {
200: z.string()
@@ -165,8 +240,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) =
void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only");
void res.header("Content-Transfer-Encoding", "base64");
const { identifier } = req.params;
const identifierType = await getIdentifierType(identifier);
if (!identifierType) {
throw new BadRequestError({ message: "Certificate template or profile not found" });
}
if (identifierType === "profile") {
return server.services.certificateEstV3.getCaCertsByProfile({
profileId: identifier
});
}
return server.services.certificateEst.getCaCerts({
certificateTemplateId: req.params.certificateTemplateId
certificateTemplateId: identifier
});
}
});

View File

@@ -352,9 +352,18 @@ export enum EventType {
UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template",
DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template",
GET_CERTIFICATE_TEMPLATE = "get-certificate-template",
LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates",
CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config",
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
CREATE_CERTIFICATE_PROFILE = "create-certificate-profile",
UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile",
DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile",
GET_CERTIFICATE_PROFILE = "get-certificate-profile",
LIST_CERTIFICATE_PROFILES = "list-certificate-profiles",
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
@@ -2512,46 +2521,6 @@ interface LoadProjectKmsBackupEvent {
metadata: Record<string, string>; // no metadata yet
}
interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
caId: string;
pkiCollectionId?: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
};
}
interface GetCertificateTemplate {
type: EventType.GET_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
};
}
interface UpdateCertificateTemplate {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
caId: string;
pkiCollectionId?: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
};
}
interface DeleteCertificateTemplate {
type: EventType.DELETE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
};
}
interface OrgAdminAccessProjectEvent {
type: EventType.ORG_ADMIN_ACCESS_PROJECT;
metadata: {
@@ -2598,6 +2567,138 @@ interface GetCertificateTemplateEstConfig {
};
}
interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata:
| {
certificateTemplateId: string;
name: string;
projectId: string;
}
| {
certificateTemplateId: string;
caId: string;
pkiCollectionId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
projectId: string;
};
}
interface UpdateCertificateTemplate {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata:
| {
certificateTemplateId: string;
name: string;
}
| {
certificateTemplateId: string;
caId: string;
pkiCollectionId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
projectId: string;
};
}
interface DeleteCertificateTemplate {
type: EventType.DELETE_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
name: string;
};
}
interface GetCertificateTemplate {
type: EventType.GET_CERTIFICATE_TEMPLATE;
metadata: {
certificateTemplateId: string;
name: string;
};
}
interface ListCertificateTemplates {
type: EventType.LIST_CERTIFICATE_TEMPLATES;
metadata: {
projectId: string;
};
}
interface CreateCertificateProfile {
type: EventType.CREATE_CERTIFICATE_PROFILE;
metadata: {
certificateProfileId: string;
name: string;
projectId: string;
enrollmentType: string;
};
}
interface UpdateCertificateProfile {
type: EventType.UPDATE_CERTIFICATE_PROFILE;
metadata: {
certificateProfileId: string;
name: string;
};
}
interface DeleteCertificateProfile {
type: EventType.DELETE_CERTIFICATE_PROFILE;
metadata: {
certificateProfileId: string;
name: string;
};
}
interface GetCertificateProfile {
type: EventType.GET_CERTIFICATE_PROFILE;
metadata: {
certificateProfileId: string;
name: string;
};
}
interface ListCertificateProfiles {
type: EventType.LIST_CERTIFICATE_PROFILES;
metadata: {
projectId: string;
};
}
interface IssueCertificateFromProfile {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE;
metadata: {
certificateProfileId: string;
certificateId: string;
commonName: string;
profileName: string;
};
}
interface SignCertificateFromProfile {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE;
metadata: {
certificateProfileId: string;
certificateId: string;
profileName: string;
commonName: string;
};
}
interface OrderCertificateFromProfile {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE;
metadata: {
certificateProfileId: string;
orderId: string;
profileName: string;
};
}
interface AttemptCreateSlackIntegration {
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
metadata: {
@@ -4051,13 +4152,22 @@ export type Event =
| LoadProjectKmsBackupEvent
| OrgAdminAccessProjectEvent
| OrgAdminBypassSSOEvent
| CreateCertificateTemplate
| UpdateCertificateTemplate
| GetCertificateTemplate
| DeleteCertificateTemplate
| CreateCertificateTemplateEstConfig
| UpdateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig
| CreateCertificateTemplate
| UpdateCertificateTemplate
| DeleteCertificateTemplate
| GetCertificateTemplate
| ListCertificateTemplates
| CreateCertificateProfile
| UpdateCertificateProfile
| DeleteCertificateProfile
| GetCertificateProfile
| ListCertificateProfiles
| IssueCertificateFromProfile
| SignCertificateFromProfile
| OrderCertificateFromProfile
| GetAzureAdCsTemplatesEvent
| AttemptCreateSlackIntegration
| AttemptReinstallSlackIntegration

View File

@@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => {
enterpriseSecretSyncs: false,
enterpriseCertificateSyncs: false,
enterpriseAppConnections: true,
machineIdentityAuthTemplates: false
machineIdentityAuthTemplates: false,
pkiLegacyTemplates: false
};
};

View File

@@ -67,6 +67,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
fips: false,
eventSubscriptions: false,
machineIdentityAuthTemplates: false,
pkiLegacyTemplates: false,
pam: false
});

View File

@@ -78,6 +78,7 @@ export type TFeatureSet = {
enterpriseCertificateSyncs: false;
enterpriseAppConnections: false;
machineIdentityAuthTemplates: false;
pkiLegacyTemplates: false;
fips: false;
eventSubscriptions: false;
pam: false;

View File

@@ -5,6 +5,7 @@ import {
ProjectPermissionAppConnectionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionCommitsActions,
ProjectPermissionDynamicSecretActions,
@@ -72,8 +73,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionPkiTemplateActions.Delete,
ProjectPermissionPkiTemplateActions.IssueCert,
ProjectPermissionPkiTemplateActions.ListCerts
ProjectPermissionPkiTemplateActions.IssueCert, // deprecated
ProjectPermissionPkiTemplateActions.ListCerts // deprecated
],
ProjectPermissionSub.CertificateTemplates
);
@@ -99,6 +100,17 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.Certificates
);
can(
[
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionCertificateProfileActions.IssueCert
],
ProjectPermissionSub.CertificateProfiles
);
can(
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
ProjectPermissionSub.Commits
@@ -443,6 +455,7 @@ const buildMemberPermissionRules = () => {
// double check if all CRUD are needed for CA and Certificates
can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities);
can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
can(
[
@@ -454,7 +467,15 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.Certificates
);
can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
can(
[
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete
],
ProjectPermissionSub.CertificateProfiles
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);

View File

@@ -111,6 +111,14 @@ export enum ProjectPermissionPkiSubscriberActions {
ListCerts = "list-certs"
}
export enum ProjectPermissionCertificateProfileActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretSyncActions {
Read = "read",
Create = "create",
@@ -249,7 +257,8 @@ export enum ProjectPermissionSub {
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions"
PamSessions = "pam-sessions",
CertificateProfiles = "certificate-profiles"
}
export type SecretSubjectFields = {
@@ -438,7 +447,8 @@ export type ProjectPermissionSet =
ProjectPermissionPamAccountActions,
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -1109,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe(
"Describe what action an entity can take."
)
}),
...GeneralPermissionSchema
]);

View File

@@ -57,6 +57,7 @@ export enum ApiDocsTags {
PkiCertificateAuthorities = "PKI Certificate Authorities",
PkiCertificates = "PKI Certificates",
PkiCertificateTemplates = "PKI Certificate Templates",
PkiCertificateProfiles = "PKI Certificate Profiles",
PkiCertificateCollections = "PKI Certificate Collections",
PkiAlerting = "PKI Alerting",
PkiSubscribers = "PKI Subscribers",

View File

@@ -167,11 +167,19 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
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 { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal";
import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal";
import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service";
import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
@@ -1036,6 +1044,10 @@ export const registerRoutes = async (
const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db);
const certificateTemplateDAL = certificateTemplateDALFactory(db);
const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db);
const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db);
const certificateProfileDAL = certificateProfileDALFactory(db);
const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db);
const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db);
const certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db);
@@ -1120,6 +1132,21 @@ export const registerRoutes = async (
licenseService
});
const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({
certificateTemplateV2DAL,
permissionService
});
const certificateProfileService = certificateProfileServiceFactory({
certificateProfileDAL,
certificateTemplateV2DAL,
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
permissionService,
kmsService,
projectDAL
});
const pkiAlertService = pkiAlertServiceFactory({
pkiAlertDAL,
pkiCollectionDAL,
@@ -2086,6 +2113,27 @@ export const registerRoutes = async (
pkiSyncQueue
});
const certificateV3Service = certificateV3ServiceFactory({
certificateDAL,
certificateAuthorityDAL,
certificateProfileDAL,
certificateTemplateV2Service,
internalCaService: internalCertificateAuthorityService,
permissionService
});
const certificateEstV3Service = certificateEstV3ServiceFactory({
internalCertificateAuthorityService,
certificateTemplateV2Service,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService,
licenseService,
certificateProfileDAL,
estEnrollmentConfigDAL
});
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
certificateAuthorityDAL,
@@ -2296,6 +2344,8 @@ export const registerRoutes = async (
auditLog: auditLogService,
auditLogStream: auditLogStreamService,
certificate: certificateService,
certificateV3: certificateV3Service,
certificateEstV3: certificateEstV3Service,
sshCertificateAuthority: sshCertificateAuthorityService,
sshCertificateTemplate: sshCertificateTemplateService,
sshHost: sshHostService,
@@ -2303,6 +2353,8 @@ export const registerRoutes = async (
certificateAuthority: certificateAuthorityService,
internalCertificateAuthority: internalCertificateAuthorityService,
certificateTemplate: certificateTemplateService,
certificateTemplateV2: certificateTemplateV2Service,
certificateProfile: certificateProfileService,
certificateAuthorityCrl: certificateAuthorityCrlService,
certificateEst: certificateEstService,
pit: pitService,

View File

@@ -0,0 +1,506 @@
import RE2 from "re2";
import { z } from "zod";
import { PkiCertificateProfilesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
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 { CertStatus } from "@app/services/certificate/certificate-types";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
body: z
.object({
projectId: z.string().min(1),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfig) {
return false;
}
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) {
return false;
}
if (data.estConfig) {
return false;
}
}
return true;
},
{
message:
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
}
),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateProfile = await server.services.certificateProfile.createProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.projectId,
data: req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.body.projectId,
event: {
type: EventType.CREATE_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
name: certificateProfile.slug,
projectId: certificateProfile.projectId,
enrollmentType: certificateProfile.enrollmentType
}
}
});
return { certificateProfile };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
querystring: z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional(),
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}),
response: {
200: z.object({
certificateProfiles: PkiCertificateProfilesSchema.extend({
metrics: z
.object({
profileId: z.string(),
totalCertificates: z.number(),
activeCertificates: z.number(),
expiredCertificates: z.number(),
expiringCertificates: z.number(),
revokedCertificates: z.number()
})
.optional(),
estConfig: z
.object({
id: z.string(),
disableBootstrapCaValidation: z.boolean(),
passphrase: z.string().optional(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
id: z.string(),
autoRenew: z.boolean(),
autoRenewDays: z.number().optional()
})
.optional()
}).array(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.LIST_CERTIFICATE_PROFILES,
metadata: {
projectId: req.query.projectId
}
}
});
return { certificateProfiles: profiles, totalCount };
}
});
server.route({
method: "GET",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
id: z.string().uuid()
}),
querystring: z.object({
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({
certificateAuthority: z
.object({
id: z.string(),
projectId: z.string(),
status: z.string(),
name: z.string()
})
.optional(),
certificateTemplate: z
.object({
id: z.string(),
projectId: z.string(),
name: z.string(),
description: z.string().optional()
})
.optional(),
estConfig: z
.object({
id: z.string(),
disableBootstrapCaValidation: z.boolean(),
passphrase: z.string(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
id: z.string(),
autoRenew: z.boolean(),
autoRenewDays: z.number().optional()
})
.optional(),
metrics: z
.object({
profileId: z.string(),
totalCertificates: z.number(),
activeCertificates: z.number(),
expiredCertificates: z.number(),
expiringCertificates: z.number(),
revokedCertificates: z.number()
})
.optional()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id
});
let result = certificateProfile;
if (req.query.includeMetrics) {
const metrics = await server.services.certificateProfile.getProfileMetrics({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id,
expiringDays: req.query.expiringDays
});
result = { ...certificateProfile, metrics };
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateProfile.projectId,
event: {
type: EventType.GET_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
name: certificateProfile.slug
}
}
});
return { certificateProfile: result };
}
});
server.route({
method: "GET",
url: "/slug/:slug",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
slug: z.string().min(1)
}),
querystring: z.object({
projectId: z.string().min(1)
}),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateProfile = await server.services.certificateProfile.getProfileBySlug({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.projectId,
slug: req.params.slug
});
return { certificateProfile };
}
});
server.route({
method: "PATCH",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
id: z.string().uuid()
}),
body: z
.object({
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
.optional(),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1).optional(),
caChain: z.string().optional()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (data.estConfig) {
return false;
}
}
return true;
},
{
message: "Cannot have EST config with API enrollment type or API config with EST enrollment type."
}
),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateProfile = await server.services.certificateProfile.updateProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id,
data: req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateProfile.projectId,
event: {
type: EventType.UPDATE_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
name: certificateProfile.slug
}
}
});
return { certificateProfile };
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
certificateProfile: PkiCertificateProfilesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateProfile = await server.services.certificateProfile.deleteProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateProfile.projectId,
event: {
type: EventType.DELETE_CERTIFICATE_PROFILE,
metadata: {
certificateProfileId: certificateProfile.id,
name: certificateProfile.slug
}
}
});
return { certificateProfile };
}
});
server.route({
method: "GET",
url: "/:id/certificates",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
id: z.string().uuid()
}),
querystring: z.object({
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
status: z.nativeEnum(CertStatus).optional(),
search: z.string().optional()
}),
response: {
200: z.object({
certificates: z.array(
z.object({
id: z.string(),
serialNumber: z.string(),
cn: z.string(),
status: z.string(),
notBefore: z.date(),
notAfter: z.date(),
revokedAt: z.date().nullable().optional(),
createdAt: z.date()
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificates = await server.services.certificateProfile.getProfileCertificates({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id,
...req.query
});
return { certificates };
}
});
};

View File

@@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: {
type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
}
}
});
@@ -121,7 +122,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
name: certificateTemplate.name,
commonName: certificateTemplate.commonName,
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl
ttl: certificateTemplate.ttl,
projectId: certificateTemplate.projectId
}
}
});
@@ -184,9 +186,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name,
caId: certificateTemplate.caId,
pkiCollectionId: certificateTemplate.pkiCollectionId as string,
name: certificateTemplate.name,
commonName: certificateTemplate.commonName,
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl
@@ -230,7 +232,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
event: {
type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
}
}
});

View File

@@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./auth-router";
import { registerProjectBotRouter } from "./bot-router";
import { registerCaRouter } from "./certificate-authority-router";
import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers";
import { registerCertificateProfilesRouter } from "./certificate-profiles-router";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateTemplateRouter } from "./certificate-template-router";
import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router";
@@ -146,6 +147,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
);
await pkiRouter.register(registerCertRouter, { prefix: "/certificates" });
await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" });
await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" });
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" });
await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" });

View File

@@ -0,0 +1,367 @@
import RE2 from "re2";
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
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 {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const templateV2SubjectSchema = z
.object({
type: attributeTypeSchema,
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Subject attribute must have at least one allowed, required, or denied value"
}
);
const templateV2KeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Key usages must have at least one allowed, required, or denied value"
}
);
const templateV2ExtendedKeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Extended key usages must have at least one allowed, required, or denied value"
}
);
const templateV2SanSchema = z
.object({
type: sanTypeSchema,
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "SAN must have at least one allowed, required, or denied value"
}
);
const templateV2ValiditySchema = z.object({
max: z
.string()
.refine(
(val) => {
if (!val) return true;
if (val.length < 2) return false;
const unit = val.slice(-1);
const number = val.slice(0, -1);
const digitRegex = new RE2("^\\d+$");
return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number);
},
{
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
}
)
.optional()
});
const templateV2AlgorithmsSchema = z.object({
signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(),
keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional()
});
const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1),
name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"),
description: z.string().max(1000).optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional()
});
const updateCertificateTemplateV2Schema = z.object({
name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(),
description: z.string().max(1000).optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional()
});
export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
body: createCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { projectId, ...data } = req.body;
const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod!,
actorOrgId: req.permission.orgId,
projectId,
data
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId,
event: {
type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name,
projectId: certificateTemplate.projectId
}
}
});
return { certificateTemplate };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
querystring: z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional()
}),
response: {
200: z.object({
certificateTemplates: certificateTemplateV2ResponseSchema.array(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod!,
actorOrgId: req.permission.orgId,
...req.query
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.query.projectId,
event: {
type: EventType.LIST_CERTIFICATE_TEMPLATES,
metadata: {
projectId: req.query.projectId
}
}
});
return { certificateTemplates: templates, totalCount };
}
});
server.route({
method: "GET",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod!,
actorOrgId: req.permission.orgId,
templateId: req.params.id
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
}
}
});
return { certificateTemplate };
}
});
server.route({
method: "PATCH",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
id: z.string().uuid()
}),
body: updateCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod!,
actorOrgId: req.permission.orgId,
templateId: req.params.id,
data: req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
}
}
});
return { certificateTemplate };
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod!,
actorOrgId: req.permission.orgId,
templateId: req.params.id
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: certificateTemplate.projectId,
event: {
type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name
}
}
});
return { certificateTemplate };
}
});
};

View File

@@ -1,4 +1,5 @@
import { registerCaRouter } from "./certificate-authority-router";
import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router";
import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router";
import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";
@@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
await server.register(registerPasswordRouter, { prefix: "/password" });
await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" });
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });

View File

@@ -0,0 +1,346 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
ACMESANType,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType
} from "@app/services/certificate-common/certificate-constants";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
interface CertificateRequestForService {
commonName?: string;
keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsageType[];
altNames?: Array<{
type: CertSubjectAlternativeNameType;
value: string;
}>;
validity: {
ttl: string;
};
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
}
const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => {
const hasDateFields = data.notBefore || data.notAfter;
const hasTtl = data.ttl;
return !(hasDateFields && hasTtl);
};
const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
if (data.notBefore && data.notAfter) {
const notBefore = new Date(data.notBefore);
const notAfter = new Date(data.notAfter);
return notBefore < notAfter;
}
return true;
};
export const registerCertificatesRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/issue-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
commonName: validateTemplateRegexField.optional(),
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
altNames: z
.array(
z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
value: z.string().min(1, "SAN value cannot be empty")
})
)
.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
}),
response: {
200: z.object({
certificate: z.string().trim(),
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
privateKey: z.string().trim().optional(),
serialNumber: z.string().trim(),
certificateId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateRequestForService: CertificateRequestForService = {
commonName: req.body.commonName,
keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages,
altNames: req.body.altNames,
validity: {
ttl: req.body.ttl
},
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 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: req.body.profileId,
certificateRequest: mappedCertificateRequest
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: req.body.profileId,
certificateId: data.certificateId,
commonName: req.body.commonName || "",
profileName: data.profileName
}
}
});
return data;
}
});
server.route({
method: "POST",
url: "/sign-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
body: z
.object({
profileId: z.string().uuid(),
csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"),
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(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
}),
response: {
200: z.object({
certificate: z.string().trim(),
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
serialNumber: z.string().trim(),
certificateId: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId,
csr: req.body.csr,
validity: {
ttl: req.body.ttl
},
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,
event: {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: req.body.profileId,
certificateId: data.certificateId,
profileName: data.profileName,
commonName: ""
}
}
});
return data;
}
});
server.route({
method: "POST",
url: "/order-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
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"),
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
commonName: validateTemplateRegexField.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
})
.refine(validateTtlAndDateFields, {
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
})
.refine(validateDateOrder, {
message: "notBefore must be earlier than notAfter"
}),
response: {
200: z.object({
orderId: z.string(),
status: z.nativeEnum(CertificateOrderStatus),
subjectAlternativeNames: z.array(
z.object({
type: z.nativeEnum(ACMESANType),
value: z.string(),
status: z.nativeEnum(CertificateOrderStatus)
})
),
authorizations: z.array(
z.object({
identifier: z.object({
type: z.nativeEnum(ACMESANType),
value: z.string()
}),
status: z.nativeEnum(CertificateOrderStatus),
expires: z.string().optional(),
challenges: z.array(
z.object({
type: z.string(),
status: z.nativeEnum(CertificateOrderStatus),
url: z.string(),
token: z.string()
})
)
})
),
finalize: z.string(),
certificate: z.string().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
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
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: data.projectId,
event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: {
certificateProfileId: req.body.profileId,
orderId: data.orderId,
profileName: data.profileName
}
}
});
return data;
}
});
};

View File

@@ -1,3 +1,4 @@
import { registerCertificatesRouter } from "./certificates-router";
import { registerDeprecatedSecretRouter } from "./deprecated-secret-router";
import { registerExternalMigrationRouter } from "./external-migration-router";
import { registerLoginRouter } from "./login-router";
@@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" });
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
await server.register(registerCertificatesRouter, { prefix: "/certificates" });
};

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns";
describe("signatureAlgorithmToAlgCfg", () => {
describe("RSA algorithms", () => {
it("should handle RSA-SHA256 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
});
});
it("should handle RSA-SHA384 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-384",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 4096
});
});
it("should handle RSA-SHA256 with RSA_3072 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_3072);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 3072
});
});
it("should handle RSA-SHA512 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-512",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
});
});
});
describe("ECDSA algorithms", () => {
it("should handle ECDSA-SHA256 with P-256 curve", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256);
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256"
});
});
it("should handle ECDSA-SHA384 with P-384 curve", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384);
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256"
});
});
it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
});
describe("hash format normalization", () => {
it("should normalize SHA256 to SHA-256", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-256");
});
it("should normalize SHA384 to SHA-384", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384);
expect(result.hash).toBe("SHA-384");
});
it("should normalize SHA512 to SHA-512", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096);
expect(result.hash).toBe("SHA-512");
});
it("should handle SHA1 format", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-1");
});
it("should handle SHA224 format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256);
expect(result.hash).toBe("SHA-224");
});
it("should handle case insensitive hash normalization", () => {
const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-256");
});
it("should handle already normalized hash formats", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256);
expect(result.hash).toBe("SHA-256");
});
it("should handle SHA-3 family hashes", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA3-256");
});
});
describe("dynamic key algorithm support", () => {
it("should support future RSA key sizes", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192");
expect(result.name).toBe("RSASSA-PKCS1-v1_5");
expect(result.hash).toBe("SHA-256");
});
it("should support future EC curves", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1");
expect(result.name).toBe("ECDSA");
expect(result.namedCurve).toBe("P-521");
expect(result.hash).toBe("SHA-256");
});
it("should support EC_P384 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
});
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import * as x509 from "@peculiar/x509";
import { crypto } from "@app/lib/crypto/cryptography";
@@ -68,6 +69,13 @@ export const parseDistinguishedName = (dn: string): TDNParts => {
export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => {
switch (keyAlgorithm) {
case CertKeyAlgorithm.RSA_3072:
return {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 3072
};
case CertKeyAlgorithm.RSA_4096:
return {
name: "RSASSA-PKCS1-v1_5",
@@ -99,6 +107,73 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => {
}
};
export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => {
// Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc.
if (!signatureAlgorithm || typeof signatureAlgorithm !== "string" || !signatureAlgorithm.includes("-")) {
throw new Error(`Invalid signature algorithm format: ${signatureAlgorithm}`);
}
const [keyType, hashType] = signatureAlgorithm.split("-");
if (!keyType || !hashType) {
throw new Error(`Malformed signature algorithm: ${signatureAlgorithm}`);
}
const normalizeHashType = (hash: string) => {
const upperHash = hash.toUpperCase();
if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1";
if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224";
if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256";
if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384";
if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512";
if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224";
if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256";
if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384";
if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512";
throw new Error(`Unsupported hash algorithm: ${hash}`);
};
const normalizedHash = hashType ? normalizeHashType(hashType) : undefined;
switch (keyType) {
case "RSA":
return {
name: "RSASSA-PKCS1-v1_5",
hash: normalizedHash || "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength:
keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : keyAlgorithm === CertKeyAlgorithm.RSA_3072 ? 3072 : 2048
};
case "ECDSA":
// eslint-disable-next-line no-case-declarations
const is384Curve =
keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384";
// eslint-disable-next-line no-case-declarations
const is521Curve = keyAlgorithm === "EC_secp521r1" || keyAlgorithm === "EC_P521";
// eslint-disable-next-line no-case-declarations
let namedCurve: string;
if (is521Curve) {
namedCurve = "P-521";
} else if (is384Curve) {
namedCurve = "P-384";
} else {
namedCurve = "P-256";
}
return {
name: "ECDSA",
namedCurve,
hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256")
};
default:
// Fallback to key algorithm default
return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm);
}
};
/**
* Return the public and private key of CA with id [caId]
* Note: credentials are returned as crypto.webcrypto.CryptoKey
@@ -111,7 +186,8 @@ export const getCaCredentials = async ({
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
kmsService,
signatureAlgorithm
}: TGetCaCredentialsDTO) => {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
@@ -132,7 +208,7 @@ export const getCaCredentials = async ({
cipherTextBlob: caSecret.encryptedPrivateKey
});
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const alg = signatureAlgorithm || keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const skObj = crypto.nativeCrypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" });
const caPrivateKey = await crypto.nativeCrypto.subtle.importKey(
"pkcs8",

View File

@@ -18,7 +18,7 @@ export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick(
export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) =>
z.object({
name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name),
projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId),
projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId),
enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance),
status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status)
});
@@ -26,7 +26,7 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) =>
export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) =>
z.object({
name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name),
projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId),
projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId),
enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance),
status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status)
});

View File

@@ -32,6 +32,8 @@ import {
CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm,
CertKeyUsage,
CertSignatureAlgorithm,
CertSignatureType,
CertStatus,
TAltNameMapping
} from "../../certificate/certificate-types";
@@ -48,7 +50,8 @@ import {
getCaCertChains,
getCaCredentials,
keyAlgorithmToAlgCfg,
parseDistinguishedName
parseDistinguishedName,
signatureAlgorithmToAlgCfg
} from "../certificate-authority-fns";
import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
@@ -1174,7 +1177,10 @@ export const internalCertificateAuthorityServiceFactory = ({
actor,
actorOrgId,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
signatureAlgorithm,
keyAlgorithm,
isFromProfile
}: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined;
@@ -1221,7 +1227,7 @@ export const internalCertificateAuthorityServiceFactory = ({
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.internalCa.activeCaCertId)
throw new BadRequestError({ message: "CA does not have a certificate installed" });
if (!ca.enableDirectIssuance && !certificateTemplate) {
if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) {
throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" });
}
@@ -1277,13 +1283,43 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const effectiveKeyAlgorithm =
(keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm);
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]);
if (signatureAlgorithm) {
const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0];
const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]);
const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]);
if (
(requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) ||
(requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa)
) {
// eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa
? CertSignatureAlgorithm.RSA_SHA256.split("-")[0]
: isEcdsaCa
? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0]
: "unknown";
throw new BadRequestError({
message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
});
}
}
// Determine signing algorithm for certificate signing
const signingAlg = signatureAlgorithm
? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm)
: keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${commonName}`,
keys: leafKeys,
signingAlgorithm: alg,
signingAlgorithm: keyGenAlg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
@@ -1296,7 +1332,8 @@ export const internalCertificateAuthorityServiceFactory = ({
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
kmsService,
signatureAlgorithm: signingAlg
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
@@ -1319,7 +1356,7 @@ export const internalCertificateAuthorityServiceFactory = ({
// handle key usages
let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? [];
if (keyUsages === undefined && !certificateTemplate) {
selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
selectedKeyUsages = isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
}
if (keyUsages === undefined && certificateTemplate) {
@@ -1405,7 +1442,7 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate,
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
signingAlgorithm: alg,
signingAlgorithm: signingAlg,
extensions
});
@@ -1517,7 +1554,9 @@ export const internalCertificateAuthorityServiceFactory = ({
notBefore,
notAfter,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
signatureAlgorithm,
keyAlgorithm
} = dto;
let collectionId = pkiCollectionId;
@@ -1563,7 +1602,7 @@ export const internalCertificateAuthorityServiceFactory = ({
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.internalCa.activeCaCertId)
throw new BadRequestError({ message: "CA does not have a certificate installed" });
if (!ca.enableDirectIssuance && !certificateTemplate) {
if (!dto.isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) {
throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" });
}
@@ -1622,7 +1661,29 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
if (signatureAlgorithm) {
const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA)
const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA);
const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA);
if (
(requestedKeyType === CertSignatureType.RSA && !isRsaCa) ||
(requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa)
) {
// eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown";
throw new BadRequestError({
message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
});
}
}
const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm;
const alg = signatureAlgorithm
? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm)
: keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const csrObj = new x509.Pkcs10CertificateRequest(csr);
@@ -1671,7 +1732,7 @@ export const internalCertificateAuthorityServiceFactory = ({
if (csrKeyUsageExtension) {
selectedKeyUsages = csrKeyUsages;
} else {
selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
selectedKeyUsages = dto.isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
}
}

View File

@@ -3,7 +3,12 @@ import { z } from "zod";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TProjectPermission } from "@app/lib/types";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -131,6 +136,9 @@ export type TIssueCertFromCaDTO = {
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: CertSignatureAlgorithm;
keyAlgorithm?: CertKeyAlgorithm;
isFromProfile?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO =
@@ -148,6 +156,9 @@ export type TSignCertFromCaDTO =
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
isFromProfile?: boolean;
}
| ({
isInternal: false;
@@ -163,6 +174,9 @@ export type TSignCertFromCaDTO =
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
isFromProfile?: boolean;
} & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = {
@@ -184,6 +198,7 @@ export type TGetCaCredentialsDTO = {
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
signatureAlgorithm?: RsaHashedImportParams | EcKeyImportParams;
};
export type TGetCaCertChainsDTO = {

View File

@@ -0,0 +1,187 @@
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum CertKeyUsageType {
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsageType {
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
OCSP_SIGNING = "ocsp_signing",
TIME_STAMPING = "time_stamping"
}
export enum CertIncludeType {
MANDATORY = "mandatory",
OPTIONAL = "optional",
PROHIBIT = "prohibit"
}
export enum CertAttributeRule {
ALLOW = "allow",
DENY = "deny"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
YEARS = "years"
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
}
export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => {
switch (usage) {
case CertKeyUsageType.DIGITAL_SIGNATURE:
return "digitalSignature";
case CertKeyUsageType.KEY_ENCIPHERMENT:
return "keyEncipherment";
case CertKeyUsageType.NON_REPUDIATION:
return "nonRepudiation";
case CertKeyUsageType.DATA_ENCIPHERMENT:
return "dataEncipherment";
case CertKeyUsageType.KEY_AGREEMENT:
return "keyAgreement";
case CertKeyUsageType.KEY_CERT_SIGN:
return "keyCertSign";
case CertKeyUsageType.CRL_SIGN:
return "cRLSign";
case CertKeyUsageType.ENCIPHER_ONLY:
return "encipherOnly";
case CertKeyUsageType.DECIPHER_ONLY:
return "decipherOnly";
default:
return usage;
}
};
export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => {
switch (usage) {
case "digitalSignature":
case "digital_signature":
return CertKeyUsageType.DIGITAL_SIGNATURE;
case "keyEncipherment":
case "key_encipherment":
return CertKeyUsageType.KEY_ENCIPHERMENT;
case "nonRepudiation":
case "non_repudiation":
return CertKeyUsageType.NON_REPUDIATION;
case "dataEncipherment":
case "data_encipherment":
return CertKeyUsageType.DATA_ENCIPHERMENT;
case "keyAgreement":
case "key_agreement":
return CertKeyUsageType.KEY_AGREEMENT;
case "keyCertSign":
case "key_cert_sign":
return CertKeyUsageType.KEY_CERT_SIGN;
case "cRLSign":
case "crl_sign":
return CertKeyUsageType.CRL_SIGN;
case "encipherOnly":
case "encipher_only":
return CertKeyUsageType.ENCIPHER_ONLY;
case "decipherOnly":
case "decipher_only":
return CertKeyUsageType.DECIPHER_ONLY;
default:
throw new Error(`Unknown key usage: ${usage}`);
}
};
export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => {
switch (usage) {
case CertExtendedKeyUsageType.CLIENT_AUTH:
return "clientAuth";
case CertExtendedKeyUsageType.SERVER_AUTH:
return "serverAuth";
case CertExtendedKeyUsageType.CODE_SIGNING:
return "codeSigning";
case CertExtendedKeyUsageType.EMAIL_PROTECTION:
return "emailProtection";
case CertExtendedKeyUsageType.OCSP_SIGNING:
return "ocspSigning";
case CertExtendedKeyUsageType.TIME_STAMPING:
return "timeStamping";
default:
return usage;
}
};
export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => {
switch (usage) {
case "clientAuth":
case "client_auth":
return CertExtendedKeyUsageType.CLIENT_AUTH;
case "serverAuth":
case "server_auth":
return CertExtendedKeyUsageType.SERVER_AUTH;
case "codeSigning":
case "code_signing":
return CertExtendedKeyUsageType.CODE_SIGNING;
case "emailProtection":
case "email_protection":
return CertExtendedKeyUsageType.EMAIL_PROTECTION;
case "ocspSigning":
case "ocsp_signing":
return CertExtendedKeyUsageType.OCSP_SIGNING;
case "timeStamping":
case "time_stamping":
return CertExtendedKeyUsageType.TIME_STAMPING;
default:
throw new Error(`Unknown extended key usage: ${usage}`);
}
};
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
}
export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384",
RSA_SHA512 = "RSA-SHA512",
ECDSA_SHA256 = "ECDSA-SHA256",
ECDSA_SHA384 = "ECDSA-SHA384",
ECDSA_SHA512 = "ECDSA-SHA512"
}
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType);
export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule);
export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect);
export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm);
export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm);

View File

@@ -0,0 +1,198 @@
import RE2 from "re2";
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
mapExtendedKeyUsageToLegacy,
mapKeyUsageToLegacy,
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "./certificate-constants";
interface CertificateRequestInput {
keyUsages?: string[];
extendedKeyUsages?: string[];
}
export const mapEnumsForValidation = <T extends CertificateRequestInput>(request: T): T => {
const mapKeyUsage = (usage: string): string => {
try {
return mapLegacyKeyUsageToStandard(usage);
} catch {
return usage;
}
};
const mapExtendedKeyUsage = (usage: string): string => {
try {
return mapLegacyExtendedKeyUsageToStandard(usage);
} catch {
return usage;
}
};
return {
...request,
keyUsages: request.keyUsages?.map(mapKeyUsage),
extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage)
} as T;
};
export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => {
if (!date) return undefined;
return date instanceof Date ? date.toISOString() : date;
};
export const bufferToString = (data: Buffer | string): string => {
return String(data);
};
export const buildCertificateSubjectFromTemplate = (
request: Record<string, unknown>,
templateAttributes?: Array<{
type: string;
allowed?: string[];
required?: string[];
denied?: string[];
}>
): Record<string, string | undefined> => {
const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = {
common_name: "commonName",
organization: "organization",
country: "country"
};
if (!templateAttributes || templateAttributes.length === 0) {
return subject;
}
templateAttributes.forEach((attr) => {
const requestKey = attributeMap[attr.type];
const value = request[requestKey];
if (value && typeof value === "string" && (attr.allowed || attr.required)) {
subject[attr.type] = value;
}
});
return subject;
};
const isWildcardPattern = (value: string): boolean => {
return value.includes("*");
};
const createWildcardRegex = (pattern: string): RE2 => {
const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g);
const escaped = pattern.replace(escapeRegex, "\\$&");
const wildcardRegex = new RE2(/\*/g);
const regexPattern = escaped.replace(wildcardRegex, ".*");
return new RE2(`^${regexPattern}$`);
};
const validateValueAgainstPatterns = (value: string, patterns: string[]): boolean => {
if (!patterns || patterns.length === 0) {
return false;
}
for (const pattern of patterns) {
if (isWildcardPattern(pattern)) {
try {
const regex = createWildcardRegex(pattern);
if (regex.test(value)) {
return true;
}
} catch {
if (pattern === value) {
return true;
}
}
} else if (pattern === value) {
return true;
}
}
return false;
};
export const buildSubjectAlternativeNamesFromTemplate = (
request: { subjectAlternativeNames?: Array<{ type: string; value: string }> },
templateSans?: Array<{
type: string;
allowed?: string[];
required?: string[];
denied?: string[];
}>
): string => {
if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) {
return "";
}
if (!templateSans || templateSans.length === 0) {
return request.subjectAlternativeNames.map((san) => san.value).join(",");
}
const allowedSans: string[] = [];
request.subjectAlternativeNames.forEach((san) => {
const templateSan = templateSans.find((template) => template.type === san.type);
if (!templateSan) {
allowedSans.push(san.value);
return;
}
if (templateSan.denied && validateValueAgainstPatterns(san.value, templateSan.denied)) {
throw new Error(`SAN value '${san.value}' is explicitly denied for type '${san.type}'`);
}
const isRequired = templateSan.required && validateValueAgainstPatterns(san.value, templateSan.required);
const isAllowed = templateSan.allowed && validateValueAgainstPatterns(san.value, templateSan.allowed);
if (isRequired || isAllowed || (!templateSan.allowed && !templateSan.required)) {
allowedSans.push(san.value);
} else {
throw new Error(`SAN value '${san.value}' is not allowed for type '${san.type}'`);
}
});
return allowedSans.join(",");
};
export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => {
return mapLegacyKeyUsageToStandard(usage);
};
export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => {
return mapKeyUsageToLegacy(usage) as CertKeyUsage;
};
export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => {
return mapLegacyExtendedKeyUsageToStandard(usage);
};
export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => {
return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage;
};
export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => {
return usages?.map(convertLegacyKeyUsage);
};
export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => {
return usages?.map(convertToLegacyKeyUsage);
};
export const convertExtendedKeyUsageArrayFromLegacy = (
usages?: CertExtendedKeyUsage[]
): CertExtendedKeyUsageType[] | undefined => {
return usages?.map(convertLegacyExtendedKeyUsage);
};
export const convertExtendedKeyUsageArrayToLegacy = (
usages?: CertExtendedKeyUsageType[]
): CertExtendedKeyUsage[] | undefined => {
return usages?.map(convertToLegacyExtendedKeyUsage);
};

View File

@@ -0,0 +1,737 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-bitwise */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { EnrollmentType } from "../certificate-profile/certificate-profile-types";
import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service";
// Mock the x509 module
vi.mock("@peculiar/x509", () => ({
Pkcs10CertificateRequest: vi.fn(),
GeneralNames: vi.fn(),
KeyUsagesExtension: vi.fn(),
ExtendedKeyUsageExtension: vi.fn(),
X509Certificate: vi.fn(),
KeyUsageFlags: {
digitalSignature: 1,
nonRepudiation: 2,
keyEncipherment: 4,
dataEncipherment: 8,
keyAgreement: 16,
keyCertSign: 32,
cRLSign: 64,
encipherOnly: 128,
decipherOnly: 256
}
}));
// Mock other dependencies
vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({
parseDistinguishedName: vi.fn((subject: string) => {
const parts = subject.split(",");
const result: any = {};
parts.forEach((part) => {
const [key, value] = part.split("=");
switch (key.trim()) {
case "CN":
result.commonName = value;
break;
case "O":
result.organization = value;
break;
case "OU":
result.ou = value;
break;
case "L":
result.locality = value;
break;
case "ST":
result.province = value;
break;
case "C":
result.country = value;
break;
default:
break;
}
});
return result;
})
}));
vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({
validateAndMapAltNameType: vi.fn((value: string) => {
if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) {
return { type: "dns", value };
}
if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) {
return { type: "ip", value };
}
return null;
})
}));
vi.mock("@app/services/certificate-common/certificate-constants", () => ({
mapLegacyKeyUsageToStandard: vi.fn((usage: string) => {
const mapping: Record<string, string> = {
digitalSignature: "digital_signature",
keyEncipherment: "key_encipherment",
keyCertSign: "key_cert_sign"
};
return mapping[usage] || usage;
}),
mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => {
const mapping: Record<string, string> = {
clientAuth: "client_auth",
serverAuth: "server_auth",
codeSigning: "code_signing"
};
return mapping[usage] || usage;
}),
CertKeyUsageType: {
DIGITAL_SIGNATURE: "digital_signature",
KEY_ENCIPHERMENT: "key_encipherment",
KEY_CERT_SIGN: "key_cert_sign"
},
CertExtendedKeyUsageType: {
CLIENT_AUTH: "client_auth",
SERVER_AUTH: "server_auth",
CODE_SIGNING: "code_signing"
},
CertSubjectAlternativeNameType: {
DNS_NAME: "dns_name",
IP_ADDRESS: "ip_address",
RFC822_NAME: "rfc822_name",
UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier"
}
}));
vi.mock("@app/services/certificate/certificate-types", () => ({
mapLegacyAltNameType: vi.fn((type: string) => {
const mapping: Record<string, string> = {
dns: "dns_name",
ip: "ip_address",
email: "rfc822_name",
url: "uniform_resource_identifier"
};
return mapping[type] || type;
}),
TAltNameType: {
EMAIL: "email",
DNS: "dns",
IP: "ip",
URL: "url"
},
CertExtendedKeyUsageOIDToName: {
"1.3.6.1.5.5.7.3.1": "serverAuth",
"1.3.6.1.5.5.7.3.2": "clientAuth",
"1.3.6.1.5.5.7.3.3": "codeSigning"
},
CertKeyUsage: {
DIGITAL_SIGNATURE: "digitalSignature",
KEY_ENCIPHERMENT: "keyEncipherment",
KEY_CERT_SIGN: "keyCertSign",
NON_REPUDIATION: "nonRepudiation",
DATA_ENCIPHERMENT: "dataEncipherment",
KEY_AGREEMENT: "keyAgreement",
CRL_SIGN: "cRLSign",
ENCIPHER_ONLY: "encipherOnly",
DECIPHER_ONLY: "decipherOnly"
},
CertExtendedKeyUsage: {
CLIENT_AUTH: "clientAuth",
SERVER_AUTH: "serverAuth",
CODE_SIGNING: "codeSigning"
}
}));
vi.mock("@app/services/certificate-common/certificate-utils", () => ({
mapEnumsForValidation: vi.fn((req: any) => req)
}));
vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({
convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response")
}));
describe("CertificateEstV3Service Security Fix", () => {
let service: TCertificateEstV3ServiceFactory;
const mockInternalCertificateAuthorityService = {
signCertFromCa: vi.fn()
};
const mockCertificateTemplateV2Service = {
validateCertificateRequest: vi.fn()
};
const mockCertificateAuthorityDAL = {
findById: vi.fn(),
findByIdWithAssociatedCa: vi.fn()
};
const mockCertificateAuthorityCertDAL = {
find: vi.fn(),
findById: vi.fn()
};
const mockProjectDAL = {
findOne: vi.fn(),
updateById: vi.fn(),
transaction: vi.fn()
};
const mockKmsService = {
decryptWithKmsKey: vi.fn(),
generateKmsKey: vi.fn()
};
const mockLicenseService = {
getPlan: vi.fn()
};
const mockCertificateProfileDAL = {
findByIdWithConfigs: vi.fn()
};
const mockEstEnrollmentConfigDAL = {
findById: vi.fn()
};
const mockProfile = {
id: "profile-123",
projectId: "project-123",
caId: "ca-123",
certificateTemplateId: "template-v2-123",
enrollmentType: EnrollmentType.EST,
estConfigId: "est-config-123"
};
const mockEstConfig = {
id: "est-config-123",
disableBootstrapCaValidation: true
};
const mockProject = {
id: "project-123",
orgId: "org-123"
};
const mockPlan = {
pkiEst: true
};
beforeEach(async () => {
const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509");
service = certificateEstV3ServiceFactory({
internalCertificateAuthorityService: mockInternalCertificateAuthorityService,
certificateTemplateV2Service: mockCertificateTemplateV2Service,
certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
projectDAL: mockProjectDAL,
kmsService: mockKmsService,
licenseService: mockLicenseService,
certificateProfileDAL: mockCertificateProfileDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL
});
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig);
mockProjectDAL.findOne.mockResolvedValue(mockProject);
mockLicenseService.getPlan.mockResolvedValue(mockPlan);
// Set up the default CSR parsing behavior
(Pkcs10CertificateRequest as any).mockImplementation((csr: string) => {
const parsed = JSON.parse(csr);
const mockExtensions: any[] = [];
if (parsed.sans && parsed.sans.length > 0) {
mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" });
}
return {
subject: parsed.subject,
extensions: mockExtensions,
getExtension: vi.fn((oid: string) => {
if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) {
// Calculate usages as bitwise OR of key usage flags
let usages = 0;
parsed.keyUsages.forEach((usage: string) => {
switch (usage) {
case "digital_signature":
usages |= 1; // KeyUsageFlags.digitalSignature
break;
case "key_encipherment":
usages |= 4; // KeyUsageFlags.keyEncipherment
break;
case "key_cert_sign":
usages |= 32; // KeyUsageFlags.keyCertSign
break;
default:
break;
}
});
return { usages };
}
if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) {
const ekuOids = parsed.extendedKeyUsages.map((eku: string) => {
switch (eku) {
case "client_auth":
return "1.3.6.1.5.5.7.3.2";
case "server_auth":
return "1.3.6.1.5.5.7.3.1";
case "code_signing":
return "1.3.6.1.5.5.7.3.3";
default:
return "1.3.6.1.5.5.7.3.1";
}
});
return { usages: ekuOids };
}
return undefined;
})
};
});
(GeneralNames as any).mockImplementation(() => ({
items: [
{ type: "dns", value: "test.example.com" },
{ type: "ip", value: "192.168.1.1" }
]
}));
});
afterEach(() => {
vi.clearAllMocks();
});
const createMockCSR = (
options: {
subject?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
sans?: Array<{ type: string; value: string }>;
} = {}
) => {
const {
subject = "CN=test.example.com,O=Test Org,C=US",
keyUsages = [],
extendedKeyUsages = [],
sans = []
} = options;
return JSON.stringify({
subject,
keyUsages,
extendedKeyUsages,
sans
});
};
describe("CSR Extraction and Template Validation", () => {
it("should extract subject attributes from CSR", async () => {
const csr = createMockCSR({
subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US"
});
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
commonName: "test.example.com",
organization: "Test Organization",
organizationUnit: "IT Department",
locality: "San Francisco",
state: "California",
country: "US"
})
);
});
it("should extract key usages from CSR", async () => {
const csr = createMockCSR({
keyUsages: ["digital_signature", "key_encipherment"]
});
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"])
})
);
});
it("should extract extended key usages from CSR", async () => {
const csr = createMockCSR({
extendedKeyUsages: ["client_auth", "server_auth"]
});
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"])
})
);
});
it("should extract Subject Alternative Names from CSR", async () => {
const { GeneralNames } = await import("@peculiar/x509");
const csr = createMockCSR({
sans: [
{ type: "dns", value: "test.example.com" },
{ type: "ip", value: "192.168.1.1" }
]
});
(GeneralNames as any).mockImplementation(() => ({
items: [
{ type: "dns", value: "test.example.com" },
{ type: "ip", value: "192.168.1.1" }
]
}));
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
subjectAlternativeNames: expect.arrayContaining([
expect.objectContaining({
type: "dns_name",
value: "test.example.com"
}),
expect.objectContaining({
type: "ip_address",
value: "192.168.1.1"
})
])
})
);
});
});
describe("Template Validation Enforcement", () => {
const basicCSR = createMockCSR();
it("should enforce template validation and reject invalid requests", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: false,
errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"],
warnings: []
});
await expect(
service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled();
});
it("should allow valid requests that pass template validation", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.any(Object)
);
expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({
isInternal: true,
caId: "ca-123",
csr: basicCSR,
isFromProfile: true
});
});
it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: false,
errors: ["SAN value 'evil.com' is denied"],
warnings: []
});
await expect(
service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled();
expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled();
});
});
describe("Policy Bypass Prevention", () => {
const maliciousCSR = createMockCSR({
subject: "CN=evil.com,O=Evil Corp,C=XX",
keyUsages: ["key_cert_sign"],
sans: [
{ type: "dns", value: "*.example.com" },
{ type: "ip", value: "127.0.0.1" }
]
});
it("should block attempts to bypass subject attribute policies", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: false,
errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"],
warnings: []
});
await expect(
service.simpleEnrollByProfile({
csr: maliciousCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
commonName: "evil.com",
organization: "Evil Corp",
country: "XX"
})
);
});
it("should block attempts to bypass key usage policies", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: false,
errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"],
warnings: []
});
await expect(
service.simpleEnrollByProfile({
csr: maliciousCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-v2-123",
expect.objectContaining({
keyUsages: expect.arrayContaining(["key_cert_sign"])
})
);
});
it("should block attempts to bypass SAN policies", async () => {
const { GeneralNames } = await import("@peculiar/x509");
(GeneralNames as any).mockImplementation(() => ({
items: [
{ type: "dns", value: "*.example.com" },
{ type: "ip", value: "127.0.0.1" }
]
}));
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: false,
errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"],
warnings: []
});
await expect(
service.simpleEnrollByProfile({
csr: maliciousCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
});
});
describe("Error Handling", () => {
const basicCSR = createMockCSR();
it("should handle profile not found", async () => {
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null);
await expect(
service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "nonexistent",
sslClientCert: ""
})
).rejects.toThrow(NotFoundError);
});
it("should handle non-EST enrollment type", async () => {
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({
...mockProfile,
enrollmentType: EnrollmentType.API
});
await expect(
service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow(BadRequestError);
});
it("should handle template validation service errors", async () => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue(
new Error("Template validation service unavailable")
);
await expect(
service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
})
).rejects.toThrow("Template validation service unavailable");
});
});
describe("Integration with existing flow", () => {
const basicCSR = createMockCSR();
beforeEach(() => {
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
});
it("should call internal CA service with correct parameters after validation", async () => {
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
});
expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({
isInternal: true,
caId: "ca-123",
isFromProfile: true,
csr: basicCSR
});
});
it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => {
mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({
certificate: { rawData: new ArrayBuffer(0) }
});
await service.simpleEnrollByProfile({
csr: basicCSR,
profileId: "profile-123",
sslClientCert: ""
});
// Verify it uses caId from profile, not certificateTemplateId
expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith(
expect.objectContaining({
caId: "ca-123"
})
);
// Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion
expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith(
expect.not.objectContaining({
certificateTemplateId: expect.anything()
})
);
});
});
});

View File

@@ -0,0 +1,408 @@
import * as x509 from "@peculiar/x509";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isCertChainValid } from "@app/services/certificate/certificate-fns";
import {
CertExtendedKeyUsageOIDToName,
CertKeyUsage,
mapLegacyAltNameType,
TAltNameMapping,
TAltNameType
} from "@app/services/certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import {
getCaCertChain,
getCaCertChains,
parseDistinguishedName
} from "@app/services/certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import {
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "@app/services/certificate-common/certificate-constants";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types";
import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns";
import { TLicenseServiceFactory } from "../../ee/services/license/license-service";
type TCertificateEstV3ServiceFactoryDep = {
internalCertificateAuthorityService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa">;
certificateTemplateV2Service: Pick<TCertificateTemplateV2ServiceFactory, "validateCertificateRequest">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById" | "findByIdWithAssociatedCa">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
estEnrollmentConfigDAL: Pick<TEstEnrollmentConfigDALFactory, "findById">;
};
export type TCertificateEstV3ServiceFactory = ReturnType<typeof certificateEstV3ServiceFactory>;
export const certificateEstV3ServiceFactory = ({
internalCertificateAuthorityService,
certificateTemplateV2Service,
certificateAuthorityCertDAL,
certificateAuthorityDAL,
projectDAL,
kmsService,
licenseService,
certificateProfileDAL,
estEnrollmentConfigDAL
}: TCertificateEstV3ServiceFactoryDep) => {
const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const subject = parseDistinguishedName(csrObj.subject);
const certificateRequest: TCertificateRequest = {
commonName: subject.commonName,
organization: subject.organization,
organizationUnit: subject.ou,
locality: subject.locality,
state: subject.province,
country: subject.country
};
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
if (csrKeyUsageExtension) {
const csrKeyUsages = Object.values(CertKeyUsage).filter(
// eslint-disable-next-line no-bitwise
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard);
}
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
if (csrExtendedKeyUsageExtension) {
const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard);
}
const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
const altNamesArray: TAltNameMapping[] = sanNames.items
.filter(
(value) =>
value.type === TAltNameType.EMAIL ||
value.type === TAltNameType.DNS ||
value.type === TAltNameType.IP ||
value.type === TAltNameType.URL
)
.map((name): TAltNameMapping => {
const altNameType = validateAndMapAltNameType(name.value);
if (!altNameType) {
throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` });
}
return altNameType;
});
certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({
type: mapLegacyAltNameType(altName.type),
value: altName.value
}));
}
return certificateRequest;
};
const simpleEnrollByProfile = async ({
csr,
profileId,
sslClientCert
}: {
csr: string;
profileId: string;
sslClientCert: string;
}) => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
}
const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
if (!estConfig) {
throw new NotFoundError({ message: "EST configuration not found" });
}
const project = await projectDAL.findOne({ id: profile.projectId });
if (!project) {
throw new NotFoundError({ message: "Project not found" });
}
const plan = await licenseService.getPlan(project.orgId);
if (!plan.pkiEst) {
throw new BadRequestError({
message:
"Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan."
});
}
if (!estConfig.disableBootstrapCaValidation) {
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: profile.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaChain = estConfig.encryptedCaChain
? (
await kmsDecryptor({
cipherTextBlob: estConfig.encryptedCaChain
})
).toString()
: "";
const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => {
return new x509.X509Certificate(cert);
});
if (!caCerts) {
throw new BadRequestError({ message: "Failed to parse certificate chain" });
}
const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
if (!leafCertificate) {
throw new UnauthorizedError({ message: "Missing client certificate" });
}
const certObj = new x509.X509Certificate(leafCertificate);
if (!(await isCertChainValid([certObj, ...caCerts]))) {
throw new BadRequestError({ message: "Invalid certificate chain" });
}
}
const certificateRequest = extractCertificateRequestFromCSR(csr);
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
);
if (!validationResult.isValid) {
throw new BadRequestError({
message: `Certificate request validation failed: ${validationResult.errors.join(", ")}`
});
}
const { certificate } = await internalCertificateAuthorityService.signCertFromCa({
isInternal: true,
caId: profile.caId,
csr,
isFromProfile: true
});
return convertRawCertsToPkcs7([certificate.rawData]);
};
const simpleReenrollByProfile = async ({
csr,
profileId,
sslClientCert
}: {
csr: string;
profileId: string;
sslClientCert: string;
}) => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
}
const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
if (!estConfig) {
throw new NotFoundError({ message: "EST configuration not found" });
}
const project = await projectDAL.findOne({ id: profile.projectId });
if (!project) {
throw new NotFoundError({ message: "Project not found" });
}
const plan = await licenseService.getPlan(project.orgId);
if (!plan.pkiEst) {
throw new BadRequestError({
message:
"Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan."
});
}
const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0];
if (!leafCertificate) {
throw new UnauthorizedError({ message: "Missing client certificate" });
}
const cert = new x509.X509Certificate(leafCertificate);
const caCertChains = await getCaCertChains({
caId: profile.caId,
certificateAuthorityCertDAL,
certificateAuthorityDAL,
projectDAL,
kmsService
});
const verifiedChains = await Promise.all(
caCertChains.map((chain) => {
const caCert = new x509.X509Certificate(chain.certificate);
const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || [];
return isCertChainValid([cert, caCert, ...caChain]);
})
);
if (!verifiedChains.some(Boolean)) {
throw new BadRequestError({
message: "Invalid client certificate: unable to build a valid certificate chain"
});
}
const csrObj = new x509.Pkcs10CertificateRequest(csr);
if (csrObj.subject !== cert.subject) {
throw new BadRequestError({
message: "Subject mismatch"
});
}
let csrSanSet: Set<string> = new Set();
const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (csrSanExtension) {
const sanNames = new x509.GeneralNames(csrSanExtension.value);
csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]);
}
let certSanSet: Set<string> = new Set();
const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17");
if (certSanExtension) {
const sanNames = new x509.GeneralNames(certSanExtension.value);
certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]);
}
if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) {
throw new BadRequestError({
message: "Subject alternative names mismatch"
});
}
const certificateRequest = extractCertificateRequestFromCSR(csr);
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
);
if (!validationResult.isValid) {
throw new BadRequestError({
message: `Certificate request validation failed: ${validationResult.errors.join(", ")}`
});
}
const { certificate } = await internalCertificateAuthorityService.signCertFromCa({
isInternal: true,
caId: profile.caId,
csr,
isFromProfile: true
});
return convertRawCertsToPkcs7([certificate.rawData]);
};
const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
}
const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId);
if (!estConfig) {
throw new NotFoundError({ message: "EST configuration not found" });
}
const project = await projectDAL.findOne({ id: profile.projectId });
if (!project) {
throw new NotFoundError({ message: "Project not found" });
}
const plan = await licenseService.getPlan(project.orgId);
if (!plan.pkiEst) {
throw new BadRequestError({
message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan."
});
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca?.internalCa?.id) {
throw new NotFoundError({
message: `Internal Certificate Authority with ID '${profile.caId}' not found`
});
}
const { caCert, caCertChain } = await getCaCertChain({
caCertId: ca.internalCa.activeCaCertId as string,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
let certificates: x509.X509Certificate[] = [];
if (caCertChain && caCertChain.trim()) {
try {
certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert));
} catch (error) {
certificates = [];
}
}
const caCertificate = new x509.X509Certificate(caCert);
return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]);
};
return {
simpleEnrollByProfile,
simpleReenrollByProfile,
getCaCertsByProfile
};
};

View File

@@ -0,0 +1,552 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import {
EnrollmentType,
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate,
TCertificateProfileWithConfigs,
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types";
export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>;
export const certificateProfileDALFactory = (db: TDbClient) => {
const certificateProfileOrm = ormify(db, TableName.PkiCertificateProfile);
const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => {
try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [
TCertificateProfile
];
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Create certificate profile" });
}
};
const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise<TCertificateProfile> => {
try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile)
.where({ id })
.update(data)
.returning("*")) as [TCertificateProfile];
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
};
const deleteById = async (id: string, tx?: Knex): Promise<TCertificateProfile> => {
try {
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile)
.where({ id })
.del()
.returning("*")) as [TCertificateProfile];
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Delete certificate profile" });
}
};
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;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by id" });
}
};
const findByIdWithConfigs = async (id: string, tx?: Knex): Promise<TCertificateProfileWithConfigs | undefined> => {
try {
const query = (tx || db)(TableName.PkiCertificateProfile)
.leftJoin(
TableName.CertificateAuthority,
`${TableName.PkiCertificateProfile}.caId`,
`${TableName.CertificateAuthority}.id`
)
.leftJoin(
TableName.PkiCertificateTemplateV2,
`${TableName.PkiCertificateProfile}.certificateTemplateId`,
`${TableName.PkiCertificateTemplateV2}.id`
)
.leftJoin(
TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`,
`${TableName.PkiEstEnrollmentConfig}.id`
)
.leftJoin(
TableName.PkiApiEnrollmentConfig,
`${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id`
)
.select(selectAllTableCols(TableName.PkiCertificateProfile))
.select(
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"),
db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"),
db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
db.ref("id").withSchema(TableName.PkiCertificateTemplateV2).as("templateId"),
db.ref("projectId").withSchema(TableName.PkiCertificateTemplateV2).as("templateProjectId"),
db.ref("name").withSchema(TableName.PkiCertificateTemplateV2).as("templateName"),
db.ref("description").withSchema(TableName.PkiCertificateTemplateV2).as("templateDescription"),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estConfigDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays")
)
.where(`${TableName.PkiCertificateProfile}.id`, id)
.first();
const result = await query;
if (!result) return undefined;
const estConfig =
result.estConfigId && result.estConfigHashedPassphrase
? ({
id: result.estConfigId,
disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation,
passphrase: result.estConfigHashedPassphrase,
caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : ""
} as TCertificateProfileWithConfigs["estConfig"])
: undefined;
const apiConfig = result.apiConfigId
? ({
id: result.apiConfigId,
autoRenew: !!result.apiConfigAutoRenew,
autoRenewDays: result.apiConfigAutoRenewDays || undefined
} as TCertificateProfileWithConfigs["apiConfig"])
: undefined;
const certificateAuthority =
result.caId && result.caProjectId && result.caStatus && result.caName
? ({
id: result.caId,
projectId: result.caProjectId,
status: result.caStatus,
name: result.caName
} as TCertificateProfileWithConfigs["certificateAuthority"])
: undefined;
const certificateTemplate =
result.templateId && result.templateProjectId && result.templateName
? ({
id: result.templateId,
projectId: result.templateProjectId,
name: result.templateName,
description: result.templateDescription || undefined
} as TCertificateProfileWithConfigs["certificateTemplate"])
: undefined;
const transformedResult: TCertificateProfileWithConfigs = {
id: result.id,
projectId: result.projectId,
caId: result.caId,
certificateTemplateId: result.certificateTemplateId,
slug: result.slug,
description: result.description,
enrollmentType: result.enrollmentType as EnrollmentType,
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
apiConfig,
certificateAuthority,
certificateTemplate
};
return transformedResult;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by id with configs" });
}
};
const findBySlugAndProjectId = async (
slug: string,
projectId: string,
tx?: Knex
): Promise<TCertificateProfile | undefined> => {
try {
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile)
.where({ slug, projectId })
.first()) as TCertificateProfile | undefined;
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" });
}
};
const findByProjectId = async (
projectId: string,
options: {
offset?: number;
limit?: number;
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
} = {},
tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[] | TCertificateProfileWithConfigs[]> => {
try {
const {
offset = 0,
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 7
} = options;
let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`,
projectId
);
if (search) {
baseQuery = baseQuery.where((builder) => {
void builder.where((qb) => {
void qb
.whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`)
.orWhereILike(`${TableName.PkiCertificateProfile}.description`, `%${search}%`);
});
});
}
if (enrollmentType) {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType);
}
if (caId) {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
}
let query = baseQuery
.leftJoin(
TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`,
`${TableName.PkiEstEnrollmentConfig}.id`
)
.leftJoin(
TableName.PkiApiEnrollmentConfig,
`${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id`
)
.select(selectAllTableCols(TableName.PkiCertificateProfile))
.select(
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays")
);
if (includeMetrics) {
query = query.leftJoin(
TableName.Certificate,
`${TableName.PkiCertificateProfile}.id`,
`${TableName.Certificate}.profileId`
);
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
query = query
.select(
selectAllTableCols(TableName.PkiCertificateProfile),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"),
db.raw("COUNT(certificates.id) as total_certificates"),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates',
[expiringDate]
),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates',
[now]
),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.groupBy(
`${TableName.PkiCertificateProfile}.id`,
`${TableName.PkiEstEnrollmentConfig}.id`,
`${TableName.PkiApiEnrollmentConfig}.id`
);
}
const results = (await query
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc")
.offset(offset)
.limit(limit)) as Record<string, unknown>[];
return results.map((result: Record<string, unknown>) => {
const estConfig =
result.estId && result.estHashedPassphrase
? {
id: result.estId as string,
disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation,
passphrase: result.estConfigHashedPassphrase,
caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : ""
}
: undefined;
const apiConfig = result.apiId
? {
id: result.apiId as string,
autoRenew: !!result.apiAutoRenew,
autoRenewDays: (result.apiAutoRenewDays as number) || undefined
}
: undefined;
const baseProfile = {
id: result.id,
projectId: result.projectId,
caId: result.caId,
certificateTemplateId: result.certificateTemplateId,
slug: result.slug,
description: result.description,
enrollmentType: result.enrollmentType as EnrollmentType,
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
apiConfig
};
if (includeMetrics) {
return {
...baseProfile,
total_certificates: result.total_certificates,
active_certificates: result.active_certificates,
expired_certificates: result.expired_certificates,
expiring_certificates: result.expiring_certificates,
revoked_certificates: result.revoked_certificates
} as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs;
}
return baseProfile as TCertificateProfileWithConfigs;
});
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profiles by project id" });
}
};
const countByProjectId = async (
projectId: string,
options: {
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
} = {},
tx?: Knex
): Promise<number> => {
try {
const { search, enrollmentType, caId } = options;
let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId });
if (search) {
query = query.where((builder) => {
void builder.where((qb) => {
void qb.whereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`);
});
});
}
if (enrollmentType) {
query = query.where({ enrollmentType });
}
if (caId) {
query = query.where({ caId });
}
const result = await query.count("*").first();
return parseInt((result as unknown as { count: string }).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count certificate profiles by project id" });
}
};
const findByNameAndProjectId = async (
name: string,
projectId: string,
tx?: Knex
): Promise<TCertificateProfile | undefined> => {
try {
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile)
.where({ slug: name, projectId })
.first()) as TCertificateProfile | undefined;
return certificateProfile;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate profile by name and project id" });
}
};
const getCertificatesByProfile = async (
profileId: string,
options: {
offset?: number;
limit?: number;
status?: "active" | "expired" | "revoked";
search?: string;
} = {},
tx?: Knex
): Promise<TCertificateProfileCertificate[]> => {
try {
const { offset = 0, limit = 20, status, search } = options;
const now = new Date();
let query = (tx || db)(TableName.Certificate).where("profileId", profileId);
if (search) {
query = query.where((builder) => {
void builder.where((qb) => {
void qb.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`);
});
});
}
if (status) {
switch (status) {
case "active":
query = query.where("notAfter", ">", now).whereNull("revokedAt");
break;
case "expired":
query = query.where("notAfter", "<=", now).whereNull("revokedAt");
break;
case "revoked":
query = query.whereNotNull("revokedAt");
break;
default:
break;
}
}
const certificates = await query
.select((tx || db).ref("id").withSchema(TableName.Certificate))
.select((tx || db).ref("serialNumber").withSchema(TableName.Certificate))
.select((tx || db).ref("cn").withSchema(TableName.Certificate))
.select((tx || db).ref("status").withSchema(TableName.Certificate))
.select((tx || db).ref("notBefore").withSchema(TableName.Certificate))
.select((tx || db).ref("notAfter").withSchema(TableName.Certificate))
.select((tx || db).ref("revokedAt").withSchema(TableName.Certificate))
.select((tx || db).ref("createdAt").withSchema(TableName.Certificate))
.orderBy("createdAt", "desc")
.offset(offset)
.limit(limit);
return certificates.map((cert) => ({
...cert,
revokedAt: cert.revokedAt ?? null
}));
} catch (error) {
throw new DatabaseError({ error, name: "Get certificates by profile" });
}
};
const getProfileMetrics = async (
profileId: string,
expiringDays: number = 7,
tx?: Knex
): Promise<TCertificateProfileMetrics> => {
try {
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
const metrics = await (tx || db)(TableName.Certificate)
.where("profileId", profileId)
.select(
db.raw("COUNT(*) as total_certificates"),
db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [
expiringDate
]),
db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]),
db.raw(
'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.first();
return {
profileId,
totalCertificates: parseInt(String((metrics as Record<string, unknown>)?.total_certificates || 0), 10),
activeCertificates: parseInt(String((metrics as Record<string, unknown>)?.active_certificates || 0), 10),
expiredCertificates: parseInt(String((metrics as Record<string, unknown>)?.expired_certificates || 0), 10),
expiringCertificates: parseInt(String((metrics as Record<string, unknown>)?.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String((metrics as Record<string, unknown>)?.revoked_certificates || 0), 10)
};
} catch (error) {
throw new DatabaseError({ error, name: "Get certificate profile metrics" });
}
};
const isProfileInUse = async (profileId: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
return parseInt((doc as unknown as { count: string }).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Check if certificate profile is in use" });
}
};
return {
...certificateProfileOrm,
create,
updateById,
deleteById,
findById,
findByIdWithConfigs,
findBySlugAndProjectId,
findByProjectId,
countByProjectId,
findByNameAndProjectId,
getCertificatesByProfile,
getProfileMetrics,
isProfileInUse
};
};

View File

@@ -0,0 +1,134 @@
import RE2 from "re2";
import { z } from "zod";
import { EnrollmentType } from "./certificate-profile-types";
export const createCertificateProfileSchema = z
.object({
projectId: z.string().uuid("Project ID must be valid"),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1),
encryptedCaChain: z.string()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfig) {
return false;
}
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) {
return false;
}
if (data.estConfig) {
return false;
}
}
return true;
},
{
message:
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
}
);
export const updateCertificateProfileSchema = z
.object({
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
.optional(),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1),
encryptedCaChain: z.string()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (data.estConfig) {
return false;
}
}
return true;
},
{
message: "Cannot have EST config with API enrollment type or API config with EST enrollment type."
}
);
export const getCertificateProfileByIdSchema = z.object({
id: z.string().uuid()
});
export const getCertificateProfileBySlugSchema = z.object({
projectId: z.string().uuid("Project ID must be valid"),
slug: z.string().min(1)
});
export const listCertificateProfilesSchema = z.object({
projectId: z.string().uuid("Project ID must be valid"),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional()
});
export const deleteCertificateProfileSchema = z.object({
id: z.string().uuid()
});
export const listCertificatesByProfileSchema = z.object({
profileId: z.string().uuid(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
status: z.enum(["active", "expired", "revoked"]).optional(),
search: z.string().optional()
});
export const getCertificateProfileMetricsSchema = z.object({
profileId: z.string().uuid(),
expiringDays: z.coerce.number().min(1).max(365).default(30)
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,824 @@
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { isCertChainValid } from "../certificate/certificate-fns";
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TProjectDALFactory } from "../project/project-dal";
import { getProjectKmsCertificateKeyId } from "../project/project-fns";
import { TCertificateProfileDALFactory } from "./certificate-profile-dal";
import {
EnrollmentType,
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate,
TCertificateProfileWithConfigs,
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types";
const validateAndEncryptPemCaChain = async (
caChain: string,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
) => {
try {
const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert));
if (!certificates || certificates.length === 0) {
throw new BadRequestError({ message: "Failed to parse certificate chain" });
}
if (!(await isCertChainValid(certificates))) {
throw new BadRequestError({ message: "Invalid certificate chain" });
}
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob } = await kmsEncryptor({
plainText: Buffer.from(caChain)
});
return { encryptedCaChain: cipherTextBlob };
} catch (error) {
throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` });
}
};
const decryptCaChain = async (
encryptedCaChain: Buffer,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey">,
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
): Promise<string> => {
try {
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaChain = await kmsDecryptor({
cipherTextBlob: encryptedCaChain
});
return decryptedCaChain.toString();
} catch (error) {
throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` });
}
};
export type TCertificateProfileCreateData = Omit<TCertificateProfileInsert, "estConfigId" | "apiConfigId"> & {
estConfig?: TEstConfigData;
apiConfig?: TApiConfigData;
};
type TCertificateProfileServiceFactoryDep = {
certificateProfileDAL: TCertificateProfileDALFactory;
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
};
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
return {
...dalResult,
enrollmentType: dalResult.enrollmentType as EnrollmentType
} as TCertificateProfile;
};
export const certificateProfileServiceFactory = ({
certificateProfileDAL,
certificateTemplateV2DAL,
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
permissionService,
kmsService,
projectDAL
}: TCertificateProfileServiceFactoryDep) => {
const createProfile = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
data
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
data: Omit<TCertificateProfileCreateData, "projectId">;
}): Promise<TCertificateProfile> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionSub.CertificateProfiles
);
// Validate that certificate template exists and belongs to the same project
if (data.certificateTemplateId) {
const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
if (template.projectId !== projectId) {
throw new ForbiddenRequestError({
message: "Certificate template must belong to the same project"
});
}
}
// Check for slug uniqueness within project
const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId);
if (existingSlugProfile) {
throw new ForbiddenRequestError({
message: "Certificate profile with this name already exists in project"
});
}
// Validate enrollment configuration requirements
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
throw new ForbiddenRequestError({
message: "EST enrollment requires EST configuration"
});
}
if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) {
throw new ForbiddenRequestError({
message: "API enrollment requires API configuration"
});
}
// Create enrollment configs and profile
const profile = await certificateProfileDAL.transaction(async (tx) => {
let estConfigId: string | null = null;
let apiConfigId: string | null = null;
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
const appCfg = getConfig();
// Hash the passphrase
const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS);
let encryptedCaChainBuffer: Buffer | null = null;
if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) {
const { encryptedCaChain } = await validateAndEncryptPemCaChain(
data.estConfig.caChain,
projectId,
kmsService,
projectDAL
);
encryptedCaChainBuffer = encryptedCaChain;
}
const estConfig = await estEnrollmentConfigDAL.create(
{
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation,
hashedPassphrase,
encryptedCaChain: encryptedCaChainBuffer
},
tx
);
estConfigId = estConfig.id;
} else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) {
const apiConfig = await apiEnrollmentConfigDAL.create(
{
autoRenew: data.apiConfig.autoRenew,
autoRenewDays: data.apiConfig.autoRenewDays
},
tx
);
apiConfigId = apiConfig.id;
}
// Create the profile with the created config IDs
const { estConfig, apiConfig, ...profileData } = data;
const profileResult = await certificateProfileDAL.create(
{
...profileData,
projectId,
estConfigId,
apiConfigId
},
tx
);
return profileResult;
});
return convertDalToService(profile);
};
const updateProfile = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
data
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
data: TCertificateProfileUpdate;
}): Promise<TCertificateProfile> => {
const existingProfile = await certificateProfileDAL.findById(profileId);
if (!existingProfile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: existingProfile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionSub.CertificateProfiles
);
if (data.certificateTemplateId) {
const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
if (template.projectId !== existingProfile.projectId) {
throw new ForbiddenRequestError({
message: "Certificate template must belong to the same project"
});
}
}
if (data.slug && data.slug !== existingProfile.slug) {
const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId(
data.slug,
existingProfile.projectId
);
if (conflictingProfile && conflictingProfile.id !== profileId) {
throw new ForbiddenRequestError({
message: "Certificate profile with this name already exists in project"
});
}
}
const { estConfig, apiConfig, ...profileUpdateData } = data;
const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
if (estConfig && existingProfile.estConfigId) {
const updateData: {
disableBootstrapCaValidation: boolean;
hashedPassphrase?: string;
encryptedCaChain?: Buffer;
} = {
disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false
};
if (estConfig.passphrase) {
updateData.hashedPassphrase = await crypto
.hashing()
.createHash(estConfig.passphrase, getConfig().SALT_ROUNDS);
}
if (estConfig.caChain) {
const { encryptedCaChain } = await validateAndEncryptPemCaChain(
estConfig.caChain,
existingProfile.projectId,
kmsService,
projectDAL
);
updateData.encryptedCaChain = encryptedCaChain;
}
await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx);
}
if (apiConfig && existingProfile.apiConfigId) {
await apiEnrollmentConfigDAL.updateById(
existingProfile.apiConfigId,
{
autoRenew: apiConfig.autoRenew,
autoRenewDays: apiConfig.autoRenewDays
},
tx
);
}
const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx);
return profileResult;
});
return convertDalToService(updatedProfile);
};
const getProfileById = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
includeMetrics = false,
expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<TCertificateProfile & { metrics?: TCertificateProfileMetrics }> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const converted = convertDalToService(profile);
if (includeMetrics) {
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return {
...converted,
metrics
};
}
return converted;
};
const getProfileByIdWithConfigs = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
}): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
if (profile.estConfig && profile.estConfig.caChain) {
try {
const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!);
if (estConfig && estConfig.encryptedCaChain) {
const decryptedCaChain = await decryptCaChain(
estConfig.encryptedCaChain,
profile.projectId,
kmsService,
projectDAL
);
profile.estConfig.caChain = decryptedCaChain;
} else {
profile.estConfig.caChain = "";
}
} catch (error) {
profile.estConfig.caChain = "";
}
}
return {
...profile,
enrollmentType: profile.enrollmentType as EnrollmentType
};
};
const getProfileBySlug = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
slug
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
slug: string;
}): Promise<TCertificateProfile> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
return convertDalToService(profile);
};
const listProfiles = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
offset = 0,
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
offset?: number;
limit?: number;
search?: string;
enrollmentType?: EnrollmentType;
caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<{
profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[];
totalCount: number;
}> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const profiles = await certificateProfileDAL.findByProjectId(projectId, {
offset,
limit,
search,
enrollmentType,
caId,
includeMetrics,
expiringDays
});
const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
search,
enrollmentType,
caId
});
const convertedProfiles = await Promise.all(
profiles.map(async (profile) => {
const profileWithConfigs = profile as TCertificateProfileWithConfigs;
let decryptedEstConfig = profileWithConfigs.estConfig;
if (decryptedEstConfig && profileWithConfigs.estConfigId) {
try {
const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId);
if (estConfig && estConfig.encryptedCaChain) {
const decryptedCaChain = await decryptCaChain(
estConfig.encryptedCaChain,
projectId,
kmsService,
projectDAL
);
decryptedEstConfig = {
...decryptedEstConfig,
caChain: decryptedCaChain
};
} else if (decryptedEstConfig) {
decryptedEstConfig = {
...decryptedEstConfig,
caChain: ""
};
}
} catch (error) {
if (decryptedEstConfig) {
decryptedEstConfig = {
...decryptedEstConfig,
caChain: ""
};
}
}
}
const converted = convertDalToService(profileWithConfigs);
let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = {
...converted,
estConfig: decryptedEstConfig,
apiConfig: profileWithConfigs.apiConfig
};
if (includeMetrics) {
const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
result = {
...result,
metrics: {
profileId: converted.id,
totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10),
activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10),
expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10),
expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10)
}
};
}
return result;
})
);
return {
profiles: convertedProfiles,
totalCount
};
};
const deleteProfile = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
}): Promise<TCertificateProfile> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionSub.CertificateProfiles
);
const deletedProfile = await certificateProfileDAL.deleteById(profileId);
if (!deletedProfile) {
throw new NotFoundError({ message: "Failed to delete certificate profile" });
}
return convertDalToService(deletedProfile);
};
const getProfileCertificates = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
offset = 0,
limit = 20,
status,
search
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
offset?: number;
limit?: number;
status?: "active" | "expired" | "revoked";
search?: string;
}): Promise<TCertificateProfileCertificate[]> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, {
offset,
limit,
status,
search
});
return certificates;
};
const getProfileMetrics = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
expiringDays?: number;
}): Promise<TCertificateProfileMetrics> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return metrics;
};
const getEstConfigurationByProfile = async (
params:
| {
profileId: string;
isInternal: true;
}
| {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string | undefined;
profileId: string;
isInternal?: false;
}
) => {
const { profileId, isInternal = false } = params;
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (!isInternal) {
const { actor, actorId, actorAuthMethod, actorOrgId } = params as {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string | undefined;
};
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
}
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new ForbiddenRequestError({
message: "Profile is not configured for EST enrollment"
});
}
if (!profile.estConfig) {
throw new NotFoundError({ message: "EST configuration not found for this profile" });
}
return {
orgId: profile.projectId,
isEnabled: true,
caChain: profile.estConfig.caChain,
disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation,
hashedPassphrase: profile.estConfig.passphrase
};
};
return {
createProfile,
updateProfile,
getProfileById,
getProfileByIdWithConfigs,
getProfileBySlug,
listProfiles,
deleteProfile,
getProfileCertificates,
getProfileMetrics,
getEstConfigurationByProfile
};
};

View File

@@ -0,0 +1,86 @@
import {
TPkiCertificateProfiles,
TPkiCertificateProfilesInsert,
TPkiCertificateProfilesUpdate
} from "@app/db/schemas/pki-certificate-profiles";
export enum EnrollmentType {
API = "api",
EST = "est"
}
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & {
enrollmentType: EnrollmentType;
};
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType"> & {
enrollmentType: EnrollmentType;
};
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType"> & {
enrollmentType?: EnrollmentType;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
};
export type TCertificateProfileWithConfigs = TCertificateProfile & {
certificateAuthority?: {
id: string;
projectId: string;
status: string;
name: string;
};
certificateTemplate?: {
id: string;
projectId: string;
name: string;
description?: string;
};
estConfig?: {
id: string;
disableBootstrapCaValidation: boolean;
passphrase: string;
caChain: string;
};
apiConfig?: {
id: string;
autoRenew: boolean;
autoRenewDays?: number;
};
metrics?: TCertificateProfileMetrics;
};
export interface TCertificateProfileMetrics {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
}
export interface TCertificateProfileCertificate {
id: string;
serialNumber: string;
cn: string;
status: string;
notBefore: Date;
notAfter: Date;
revokedAt: Date | null;
createdAt: Date;
}
export type TCertificateProfileWithRawMetrics = TCertificateProfile & {
total_certificates?: string;
active_certificates?: string;
expired_certificates?: string;
expiring_certificates?: string;
revoked_certificates?: string;
};

View File

@@ -0,0 +1,244 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import {
TCertificateTemplateV2,
TCertificateTemplateV2Insert,
TCertificateTemplateV2Update
} from "./certificate-template-v2-types";
export type TCertificateTemplateV2DALFactory = ReturnType<typeof certificateTemplateV2DALFactory>;
interface CountResult {
count: string;
}
export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const certificateTemplateV2Orm = ormify(db, TableName.PkiCertificateTemplateV2);
const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
const serialized = { ...data } as Record<string, unknown>;
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
jsonFields.forEach((field) => {
const value = serialized[field];
if (value !== undefined && typeof value !== "string") {
serialized[field] = JSON.stringify(value);
}
});
return serialized;
};
const parseJsonFields = (raw: Record<string, unknown>): TCertificateTemplateV2 => {
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
const parsed = { ...raw } as Record<string, unknown>;
jsonFields.forEach((field) => {
const value = raw[field];
if (value !== null && value !== undefined) {
if (typeof value === "string") {
try {
parsed[field] = JSON.parse(value);
} catch (error) {
throw new Error(
`Invalid JSON in field '${field}': ${error instanceof Error ? error.message : "Parse error"}`
);
}
} else {
parsed[field] = value;
}
} else {
parsed[field] = undefined;
}
});
return parsed as TCertificateTemplateV2;
};
const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => {
try {
const serializedData = serializeJsonFields(data);
const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2)
.insert(serializedData as TPkiCertificateTemplatesV2Insert)
.returning("*")) as Record<string, unknown>[];
if (!certificateTemplateV2) {
throw new Error("Failed to create certificate template v2");
}
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Create certificate template v2" });
}
};
const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => {
try {
const serializedData = serializeJsonFields(data);
const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2)
.where({ id })
.update(serializedData)
.returning("*")) as Record<string, unknown>[];
if (!certificateTemplateV2) {
return null;
}
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate template v2" });
}
};
const deleteById = async (id: string, tx?: Knex) => {
try {
const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2)
.where({ id })
.del()
.returning("*")) as Record<string, unknown>[];
return certificateTemplateV2;
} catch (error) {
throw new DatabaseError({ error, name: "Delete certificate template v2" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2).where({ id }).first()) as
| Record<string, unknown>
| undefined;
if (!certificateTemplateV2) {
return null;
}
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate template v2 by id" });
}
};
const findByProjectId = async (
projectId: string,
options: {
offset?: number;
limit?: number;
search?: string;
} = {},
tx?: Knex
) => {
try {
const { offset = 0, limit = 20, search } = options;
let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId });
if (search) {
query = query.where((builder) => {
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit);
return certificateTemplatesV2.map((template: Record<string, unknown>) => parseJsonFields(template));
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" });
}
};
const countByProjectId = async (
projectId: string,
options: {
search?: string;
} = {},
tx?: Knex
) => {
try {
const { search } = options;
let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId });
if (search) {
query = query.where((builder) => {
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
const result = await query.count("*").first();
return parseInt((result as unknown as { count: string }).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" });
}
};
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
try {
const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2)
.where({ name, projectId })
.first()) as Record<string, unknown> | undefined;
if (!certificateTemplateV2) {
return null;
}
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" });
}
};
const isTemplateInUse = async (templateId: string, tx?: Knex) => {
try {
const profileCount = await (tx || db)(TableName.PkiCertificateProfile)
.where({ certificateTemplateId: templateId })
.count("*")
.first();
const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0;
const certCount = await (tx || db)(TableName.Certificate)
.where({ certificateTemplateId: templateId })
.count("*")
.first();
const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0;
return profileUsage || certUsage;
} catch (error) {
throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" });
}
};
const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => {
try {
const profiles = await (tx || db)(TableName.PkiCertificateProfile)
.select("id", "slug", "description")
.where({ certificateTemplateId: templateId });
return profiles as Array<{ id: string; slug: string; description?: string }>;
} catch (error) {
throw new DatabaseError({ error, name: "Get profiles using certificate template v2" });
}
};
return {
...certificateTemplateV2Orm,
create,
updateById,
deleteById,
findById,
findByProjectId,
countByProjectId,
findByNameAndProjectId,
isTemplateInUse,
getProfilesUsingTemplate
};
};

View File

@@ -0,0 +1,181 @@
import RE2 from "re2";
import { z } from "zod";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const templateV2SubjectSchema = z
.object({
type: attributeTypeSchema,
allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Subject attribute must have at least one allowed, required, or denied value"
}
);
const templateV2KeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Key usages must have at least one allowed, required, or denied value"
}
);
const templateV2ExtendedKeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Extended key usages must have at least one allowed, required, or denied value"
}
);
const templateV2SanSchema = z
.object({
type: sanTypeSchema,
allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "SAN must have at least one allowed, required, or denied value"
}
);
const templateV2ValiditySchema = z.object({
max: z
.string()
.regex(new RE2("^\\d+[dhmy]$"), {
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
})
.optional()
});
const templateV2AlgorithmsSchema = z.object({
signature: z
.array(z.string().trim().min(1, "Algorithm cannot be empty"))
.min(1, "At least one signature algorithm must be provided")
.optional(),
keyAlgorithm: z
.array(z.string().trim().min(1, "Algorithm cannot be empty"))
.min(1, "At least one key algorithm must be provided")
.optional()
});
export const certificateTemplateV2ResponseSchema = z.object({
id: z.string().uuid(),
projectId: z.string().uuid("Project ID must be valid"),
name: z
.string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"),
description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export const certificateRequestSchema = z.object({
commonName: z
.string()
.trim()
.min(1, "Common name cannot be empty")
.max(64, "Common name must be less than 64 characters")
.optional(),
organization: z
.string()
.trim()
.min(1, "Organization cannot be empty")
.max(64, "Organization must be less than 64 characters")
.optional(),
country: z
.string()
.trim()
.min(2, "Country code must be 2 characters")
.max(2, "Country code must be 2 characters")
.optional(),
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(),
extendedKeyUsages: z
.array(z.nativeEnum(CertExtendedKeyUsageType))
.min(1, "At least one extended key usage must be provided")
.optional(),
subjectAlternativeNames: z
.array(
z.object({
type: sanTypeSchema,
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 SAN must be provided")
.optional(),
validity: z
.object({
ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'")
})
.optional(),
signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(),
keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional()
});
export const validateCertificateRequestSchema = z.object({
templateId: z.string().uuid(),
request: certificateRequestSchema
});

View File

@@ -0,0 +1,959 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
import {
TCertificateRequest,
TCertificateTemplateV2,
TCertificateTemplateV2Insert,
TCertificateTemplateV2Update,
TTemplateValidationResult
} from "./certificate-template-v2-types";
type TCertificateTemplateV2ServiceFactoryDep = {
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export const certificateTemplateV2ServiceFactory = ({
certificateTemplateV2DAL,
permissionService
}: TCertificateTemplateV2ServiceFactoryDep) => {
const consolidateAttributeArray = <
T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] }
>(
attributes: T[]
): T[] => {
const consolidated = new Map<string, T>();
attributes.forEach((attr) => {
const existing = consolidated.get(attr.type);
if (existing) {
throw new ForbiddenRequestError({
message: `Duplicate attribute type '${attr.type}' found in request. Each attribute type must appear only once.`
});
} else {
consolidated.set(attr.type, attr);
}
});
return Array.from(consolidated.values());
};
const parseTTL = (ttl: string): number => {
const regex = new RE2("^(\\d+)([dmyh])$");
const match = regex.exec(ttl);
if (!match) {
throw new Error(`Invalid TTL format: ${ttl}`);
}
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case "h":
return value * 60 * 60 * 1000;
case "d":
return value * 24 * 60 * 60 * 1000;
case "m":
return value * 30 * 24 * 60 * 60 * 1000;
case "y":
return value * 365 * 24 * 60 * 60 * 1000;
default:
throw new Error(`Unsupported TTL unit: ${unit}`);
}
};
const validateSubjectAttributePolicy = (
subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
) => {
if (!subject || subject.length === 0) return;
// Validate each subject attribute policy
for (const attr of subject) {
// Ensure at least one field is provided
if (!attr.allowed && !attr.required && !attr.denied) {
throw new ForbiddenRequestError({
message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value`
});
}
// Check for duplicate values within arrays
const arrays = [
{ name: "allowed", values: attr.allowed },
{ name: "required", values: attr.required },
{ name: "denied", values: attr.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'`
});
}
}
}
}
};
const validateSanPolicy = (
sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
) => {
if (!sans || sans.length === 0) return;
// Validate each SAN policy
for (const san of sans) {
if (!san.allowed && !san.required && !san.denied) {
throw new ForbiddenRequestError({
message: `SAN type '${san.type}' must have at least one allowed, required, or denied value`
});
}
const arrays = [
{ name: "allowed", values: san.allowed },
{ name: "required", values: san.required },
{ name: "denied", values: san.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} list for SAN type '${san.type}'`
});
}
}
}
}
};
const generateTemplateSlug = (baseName?: string): string => {
if (baseName) {
return slugify(baseName);
}
return slugify(alphaNumericNanoId(12));
};
const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise<string> => {
const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId);
if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
return desiredSlug;
}
const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId);
if (!existingAlternative) {
return alternativeSlug;
}
const randomSlug = slugify(alphaNumericNanoId(12));
return randomSlug;
};
const isWildcardPattern = (value: string): boolean => {
return value.includes("*");
};
const createWildcardRegex = (pattern: string): RegExp => {
const wildcardRegex = new RE2(/\*/g);
const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__");
const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g);
const escaped = withPlaceholder.replace(escapeRegex, "\\$&");
const placeholderRegex = new RE2(/__WILDCARD__/g);
const regexPattern = escaped.replace(placeholderRegex, ".*");
return new RE2(`^${regexPattern}$`);
};
const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"SHA256-RSA": "RSA-SHA256",
"SHA384-RSA": "RSA-SHA384",
"SHA512-RSA": "RSA-SHA512",
"SHA256-ECDSA": "ECDSA-SHA256",
"SHA384-ECDSA": "ECDSA-SHA384",
"SHA512-ECDSA": "ECDSA-SHA512"
};
return mapping[templateFormat] || templateFormat;
};
const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"RSA-2048": "RSA_2048",
"RSA-3072": "RSA_3072",
"RSA-4096": "RSA_4096",
"ECDSA-P256": "EC_prime256v1",
"ECDSA-P384": "EC_secp384r1",
"ECDSA-P521": "EC_secp521r1"
};
return mapping[templateFormat] || templateFormat;
};
const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => {
if (!keyUsages) return;
if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) {
throw new ForbiddenRequestError({
message: "Key usages must have at least one allowed, required, or denied value"
});
}
const arrays = [
{ name: "allowed", values: keyUsages.allowed },
{ name: "required", values: keyUsages.required },
{ name: "denied", values: keyUsages.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} key usages list`
});
}
}
}
};
const validateExtendedKeyUsagePolicy = (extendedKeyUsages: {
allowed?: string[];
required?: string[];
denied?: string[];
}) => {
if (!extendedKeyUsages) return;
if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) {
throw new ForbiddenRequestError({
message: "Extended key usages must have at least one allowed, required, or denied value"
});
}
const arrays = [
{ name: "allowed", values: extendedKeyUsages.allowed },
{ name: "required", values: extendedKeyUsages.required },
{ name: "denied", values: extendedKeyUsages.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} extended key usages list`
});
}
}
}
};
const validateValueAgainstConstraints = (
value: string,
allowedValues: string[],
fieldName: string
): { isValid: boolean; error?: string } => {
if (!allowedValues || allowedValues.length === 0) {
return { isValid: true };
}
const hasWildcards = allowedValues.some(isWildcardPattern);
for (const allowedValue of allowedValues) {
if (isWildcardPattern(allowedValue)) {
try {
const regex = createWildcardRegex(allowedValue);
if (regex.test(value)) {
return { isValid: true };
}
} catch (error) {
if (allowedValue === value) {
return { isValid: true };
}
}
} else if (allowedValue === value) {
return { isValid: true };
}
}
if (hasWildcards) {
return {
isValid: false,
error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}`
};
}
return {
isValid: false,
error: `${fieldName} value '${value}' is not in allowed values list`
};
};
const validateRequestAgainstPolicy = (
template: TCertificateTemplateV2,
request: TCertificateRequest
): TTemplateValidationResult => {
const errors: string[] = [];
const warnings: string[] = [];
// Validate subject attributes
const subjectPolicies = template.subject;
const requestAttributes = new Map<string, string>();
if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName);
if (request.organization) {
requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization);
}
if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country);
if (subjectPolicies && subjectPolicies.length > 0) {
for (const attrPolicy of subjectPolicies) {
const requestValue = requestAttributes.get(attrPolicy.type);
if (attrPolicy.required && attrPolicy.required.length > 0) {
if (!requestValue) {
errors.push(`Missing required ${attrPolicy.type} attribute`);
} else {
// Validate that the request value matches the required pattern
const hasMatchingRequired = attrPolicy.required.some((requiredValue) => {
const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type);
return validation.isValid;
});
if (!hasMatchingRequired) {
errors.push(
`${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}`
);
}
}
}
if (requestValue) {
let isValueDenied = false;
if (attrPolicy.denied && attrPolicy.denied.length > 0) {
const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type);
if (validation.isValid) {
errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`);
isValueDenied = true;
}
}
if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
let satisfiesRequired = false;
if (attrPolicy.required && attrPolicy.required.length > 0) {
satisfiesRequired = attrPolicy.required.some((requiredValue) => {
const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type);
return validation.isValid;
});
}
if (!satisfiesRequired) {
const allowedValidation = validateValueAgainstConstraints(
requestValue,
attrPolicy.allowed,
attrPolicy.type
);
if (!allowedValidation.isValid && allowedValidation.error) {
errors.push(allowedValidation.error);
}
}
}
}
}
// Check if any request attributes are not covered by template policies
for (const [attrType] of requestAttributes) {
const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType);
if (!hasPolicy) {
errors.push(`${attrType} is not allowed by template policy (not defined in template)`);
}
}
} else if (requestAttributes.size > 0) {
// No subject policies defined but request has subject attributes - deny all
for (const [attrType] of requestAttributes) {
errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`);
}
}
// Validate Subject Alternative Names
const sansPolicies = template.sans;
if (sansPolicies && sansPolicies.length > 0) {
const requestSansByType = new Map<string, string[]>();
// Group request SANs by type
if (request.subjectAlternativeNames) {
for (const san of request.subjectAlternativeNames) {
if (!requestSansByType.has(san.type)) {
requestSansByType.set(san.type, []);
}
requestSansByType.get(san.type)!.push(san.value);
}
}
// Validate each SAN policy
for (const sanPolicy of sansPolicies) {
const requestSans = requestSansByType.get(sanPolicy.type) || [];
// Check REQUIRED values - at least one SAN must match each required pattern
if (sanPolicy.required && sanPolicy.required.length > 0) {
for (const requiredValue of sanPolicy.required) {
const hasMatchingRequiredSan = requestSans.some((sanValue) => {
const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`);
return validation.isValid;
});
if (!hasMatchingRequiredSan) {
errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`);
}
}
}
// Check DENIED values - no SAN should match denied patterns
if (sanPolicy.denied && sanPolicy.denied.length > 0) {
for (const sanValue of requestSans) {
const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`);
if (validation.isValid) {
errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`);
}
}
}
// Check ALLOWED values - if present, all SANs must match at least one allowed pattern
if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) {
for (const sanValue of requestSans) {
let satisfiesRequired = false;
if (sanPolicy.required && sanPolicy.required.length > 0) {
satisfiesRequired = sanPolicy.required.some((requiredValue) => {
const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`);
return validation.isValid;
});
}
if (!satisfiesRequired) {
const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
}
}
}
}
}
// Check if any request SANs are for types not covered by template policies
for (const [requestSanType] of requestSansByType) {
const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType);
if (!hasPolicy) {
errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`);
}
}
} else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) {
// No SAN policies defined but request has SANs - deny all
for (const san of request.subjectAlternativeNames) {
errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`);
}
}
// Validate key usages
const keyUsagePolicy = template.keyUsages;
if (keyUsagePolicy) {
// Check REQUIRED key usages - must have all required usages
if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) {
const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage));
if (missingRequired.length > 0) {
errors.push(`Missing required key usages: ${missingRequired.join(", ")}`);
}
}
// Check DENIED key usages - must not have any denied usages
if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) {
const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage));
if (deniedUsages.length > 0) {
errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`);
}
}
// Check ALLOWED key usages - if present, all usages must be in allowed list
if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) {
const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])];
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`);
}
}
} else if (request.keyUsages && request.keyUsages.length > 0) {
errors.push(`Key usages are not allowed by template policy (not defined in template)`);
}
// Validate extended key usages
const extendedKeyUsagePolicy = template.extendedKeyUsages;
if (extendedKeyUsagePolicy) {
// Check REQUIRED extended key usages - must have all required usages
if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) {
const missingRequired = extendedKeyUsagePolicy.required.filter(
(usage) => !request.extendedKeyUsages?.includes(usage)
);
if (missingRequired.length > 0) {
errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`);
}
}
// Check DENIED extended key usages - must not have any denied usages
if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) {
const deniedUsages = request.extendedKeyUsages.filter((usage) =>
extendedKeyUsagePolicy?.denied?.includes(usage)
);
if (deniedUsages.length > 0) {
errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`);
}
}
// Check ALLOWED extended key usages - if present, all usages must be in allowed list
if (
request.extendedKeyUsages &&
extendedKeyUsagePolicy &&
extendedKeyUsagePolicy.allowed &&
extendedKeyUsagePolicy.allowed.length > 0
) {
const allAllowedExtendedUsages = [
...(extendedKeyUsagePolicy.required || []),
...(extendedKeyUsagePolicy.allowed || [])
];
const invalidExtendedUsages = request.extendedKeyUsages.filter(
(usage) => !allAllowedExtendedUsages.includes(usage)
);
if (invalidExtendedUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`);
}
}
} else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) {
errors.push(`Extended key usages are not allowed by template policy (not defined in template)`);
}
// Validate algorithms with new structure
if (request.signatureAlgorithm) {
if (template.algorithms?.signature && template.algorithms.signature.length > 0) {
const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi);
if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) {
errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
}
} else if (!template.algorithms?.signature) {
errors.push(
`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)`
);
}
}
if (request.keyAlgorithm) {
if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) {
const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi);
if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) {
errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`);
}
} else if (!template.algorithms?.keyAlgorithm) {
errors.push(
`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)`
);
}
}
// Validate validity with new structure
if (request.validity?.ttl && (request.notBefore || request.notAfter)) {
errors.push(
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
);
}
if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) {
errors.push("notBefore must be earlier than notAfter");
}
// Validate TTL against template validity constraints
if (request.validity?.ttl && template.validity) {
const requestDurationMs = parseTTL(request.validity.ttl);
// Check maximum duration using max field
if (template.validity.max) {
const maxDurationMs = parseTTL(template.validity.max);
if (requestDurationMs > maxDurationMs) {
errors.push("Requested validity period exceeds maximum allowed duration");
}
}
}
// Validate explicit date range against max duration
if ((request.notBefore || request.notAfter) && template.validity?.max) {
const notBefore = request.notBefore || new Date();
const { notAfter } = request;
if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) {
const requestDuration = notAfter.getTime() - notBefore.getTime();
const maxDurationMs = parseTTL(template.validity.max);
if (requestDuration > maxDurationMs) {
errors.push(
`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}`
);
}
}
}
return {
isValid: errors.length === 0,
errors,
warnings
};
};
const createTemplateV2 = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
data
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
data: Omit<TCertificateTemplateV2Insert, "projectId">;
}): Promise<TCertificateTemplateV2> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionSub.CertificateTemplates
);
if (!data) {
throw new Error("Template data is required");
}
const consolidatedData = {
...data,
subject: data.subject ? consolidateAttributeArray(data.subject) : undefined,
sans: data.sans ? consolidateAttributeArray(data.sans) : undefined
};
if (consolidatedData.subject) {
validateSubjectAttributePolicy(consolidatedData.subject);
}
if (consolidatedData.sans) {
validateSanPolicy(consolidatedData.sans);
}
if (consolidatedData.keyUsages) {
validateKeyUsagePolicy(consolidatedData.keyUsages);
}
if (consolidatedData.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages);
}
// Generate slug from name and ensure it's unique within project
if (!data.name) {
throw new ForbiddenRequestError({ message: "Template name is required" });
}
const slug = generateTemplateSlug(data.name);
const uniqueSlug = await ensureUniqueSlug(projectId, slug);
const template = await certificateTemplateV2DAL.create({
...consolidatedData,
name: uniqueSlug,
projectId
});
return template;
};
const updateTemplateV2 = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId,
data
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
templateId: string;
data: TCertificateTemplateV2Update;
}): Promise<TCertificateTemplateV2> => {
const existingTemplate = await certificateTemplateV2DAL.findById(templateId);
if (!existingTemplate) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: existingTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionSub.CertificateTemplates
);
const consolidatedData = {
...data,
subject: data.subject ? consolidateAttributeArray(data.subject) : undefined,
sans: data.sans ? consolidateAttributeArray(data.sans) : undefined
};
if (consolidatedData.subject) {
validateSubjectAttributePolicy(consolidatedData.subject);
}
if (consolidatedData.sans) {
validateSanPolicy(consolidatedData.sans);
}
if (consolidatedData.keyUsages) {
validateKeyUsagePolicy(consolidatedData.keyUsages);
}
if (consolidatedData.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages);
}
const updateData = { ...consolidatedData };
if (data.name && typeof data.name === "string") {
const newSlug = generateTemplateSlug(data.name);
if (newSlug !== existingTemplate.name) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId);
updateData.name = uniqueSlug;
}
}
const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData);
if (!updatedTemplate) {
throw new NotFoundError({ message: "Failed to update certificate template" });
}
return updatedTemplate;
};
const getTemplateV2ById = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
templateId: string;
}): Promise<TCertificateTemplateV2> => {
const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: template.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
return template;
};
const getTemplateV2BySlug = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
slug
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
slug: string;
}): Promise<TCertificateTemplateV2> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
return template;
};
const listTemplatesV2 = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
offset = 0,
limit = 20,
search
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
projectId: string;
offset?: number;
limit?: number;
search?: string;
}): Promise<{
templates: TCertificateTemplateV2[];
totalCount: number;
}> => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
const templates = await certificateTemplateV2DAL.findByProjectId(projectId, {
offset,
limit,
search
});
const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search });
return {
templates,
totalCount
};
};
const deleteTemplateV2 = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
templateId: string;
}): Promise<TCertificateTemplateV2> => {
const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: template.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Delete,
ProjectPermissionSub.CertificateTemplates
);
const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId);
if (isInUse) {
const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId);
const profileNames = profilesUsingTemplate
.map((profile: { slug?: string; id: string }) => profile.slug || profile.id)
.join(", ");
throw new ForbiddenRequestError({
message:
profilesUsingTemplate.length > 0
? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.`
: `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.`
});
}
const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId);
if (!deletedTemplate) {
throw new NotFoundError({ message: "Failed to delete certificate template" });
}
return deletedTemplate as TCertificateTemplateV2;
};
const validateCertificateRequest = async (
templateId: string,
request: TCertificateRequest
): Promise<TTemplateValidationResult> => {
const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
return validateRequestAgainstPolicy(template, request);
};
return {
createTemplateV2,
updateTemplateV2,
getTemplateV2ById,
getTemplateV2BySlug,
listTemplatesV2,
deleteTemplateV2,
validateCertificateRequest
};
};
export type TCertificateTemplateV2ServiceFactory = ReturnType<typeof certificateTemplateV2ServiceFactory>;

View File

@@ -0,0 +1,98 @@
import {
TPkiCertificateTemplatesV2,
TPkiCertificateTemplatesV2Insert
} from "@app/db/schemas/pki-certificate-templates-v2";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
export interface TTemplateV2Policy {
subject?: Array<{
type: CertSubjectAttributeType;
allowed?: string[];
required?: string[];
denied?: string[];
}>;
sans?: Array<{
type: CertSubjectAlternativeNameType;
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages?: {
allowed?: CertKeyUsageType[];
required?: CertKeyUsageType[];
denied?: CertKeyUsageType[];
};
extendedKeyUsages?: {
allowed?: CertExtendedKeyUsageType[];
required?: CertExtendedKeyUsageType[];
denied?: CertExtendedKeyUsageType[];
};
algorithms?: {
signature?: string[];
keyAlgorithm?: string[];
};
validity?: {
max?: string;
};
}
export type TCertificateTemplateV2 = TPkiCertificateTemplatesV2 & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
};
export type TCertificateTemplateV2Insert = TPkiCertificateTemplatesV2Insert & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
};
export type TCertificateTemplateV2Update = Partial<
Pick<
TCertificateTemplateV2,
"name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity"
>
>;
export interface TCertificateRequest {
commonName?: string;
organization?: string;
organizationUnit?: string;
locality?: string;
state?: string;
country?: string;
email?: string;
streetAddress?: string;
postalCode?: string;
keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsageType[];
subjectAlternativeNames?: Array<{
type: CertSubjectAlternativeNameType;
value: string;
}>;
validity?: {
ttl: string;
};
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
}
export interface TTemplateValidationResult {
isValid: boolean;
errors: string[];
warnings: string[];
}

View File

@@ -420,7 +420,7 @@ export const certificateTemplateServiceFactory = ({
};
const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => {
const { certificateTemplateId } = dto;
const { certificateTemplateId, isInternal } = dto;
const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId);
if (!certTemplate) {
@@ -429,7 +429,7 @@ export const certificateTemplateServiceFactory = ({
});
}
if (!dto.isInternal) {
if (!isInternal) {
const { permission } = await permissionService.getProjectPermission({
actor: dto.actor,
actorId: dto.actorId,
@@ -440,7 +440,7 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
}

View File

@@ -3,26 +3,40 @@ import z from "zod";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
export const validateTemplateRegexField = z
.string()
.min(1)
.max(100)
.refine(
(val) =>
characterValidator([
CharacterType.AlphaNumeric,
CharacterType.Spaces, // (space)
CharacterType.Asterisk, // *
CharacterType.At, // @
CharacterType.Hyphen, // -
CharacterType.Period, // .
CharacterType.Backslash // \
])(val),
{
message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
}
)
// we ensure that the inputted pattern is computationally safe by limiting star height to 1
.refine((v) => safe(v), {
message: "Unsafe REGEX pattern"
});
export const createTemplateFieldValidator = (options?: {
minLength?: number;
maxLength?: number;
allowedCharacters?: CharacterType[];
customMessage?: string;
}) => {
const {
minLength = 1,
maxLength = 100,
allowedCharacters = [
CharacterType.AlphaNumeric,
CharacterType.Spaces, // (space)
CharacterType.Asterisk, // *
CharacterType.At, // @
CharacterType.Hyphen, // -
CharacterType.Period, // .
CharacterType.Backslash // \
],
customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
} = options || {};
return (
z
.string()
.min(minLength)
.max(maxLength)
.refine((val) => characterValidator(allowedCharacters)(val), {
message: customMessage
})
// we ensure that the inputted pattern is computationally safe by limiting star height to 1
.refine((v) => safe(v), {
message: "Unsafe REGEX pattern"
})
);
};
export const validateTemplateRegexField = createTemplateFieldValidator();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,487 @@
import { ForbiddenError } from "@casl/ability";
import { randomUUID } from "crypto";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import {
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
import {
TCertificateAuthorityDALFactory,
TCertificateAuthorityWithAssociatedCa
} from "@app/services/certificate-authority/certificate-authority-dal";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants";
import {
bufferToString,
buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayToLegacy,
mapEnumsForValidation,
normalizeDateForApi
} from "../certificate-common/certificate-utils";
import {
TCertificateFromProfileResponse,
TCertificateOrderResponse,
TIssueCertificateFromProfileDTO,
TOrderCertificateFromProfileDTO,
TSignCertificateFromProfileDTO
} from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateTemplateV2Service: Pick<
TCertificateTemplateV2ServiceFactory,
"validateCertificateRequest" | "getTemplateV2ById"
>;
internalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
const validateProfileAndPermissions = async (
profileId: string,
actor: ActorType,
actorId: string,
actorAuthMethod: ActorAuthMethod,
actorOrgId: string,
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">,
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">,
requiredEnrollmentType: EnrollmentType
) => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== requiredEnrollmentType) {
throw new ForbiddenRequestError({
message: `Profile is not configured for ${requiredEnrollmentType} enrollment`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
return profile;
};
const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType !== CaType.INTERNAL) {
throw new BadRequestError({ message: `Only internal CAs support ${operation}` });
}
return caType;
};
const validateAlgorithmCompatibility = (
ca: TCertificateAuthorityWithAssociatedCa,
template: {
algorithms?: {
signature?: string[];
};
}
) => {
if (!template.algorithms?.signature || template.algorithms.signature.length === 0) {
return;
}
const caKeyAlgorithm = ca.internalCa?.keyAlgorithm;
if (!caKeyAlgorithm) {
throw new BadRequestError({ message: "CA key algorithm not found" });
}
const compatibleAlgorithms =
template.algorithms?.signature?.filter((sigAlg: string) => {
const parts = sigAlg.split("-");
if (parts.length === 0) {
return false;
}
const keyType = parts[parts.length - 1];
if (caKeyAlgorithm.startsWith("RSA")) {
return keyType === "RSA";
}
if (caKeyAlgorithm.startsWith("EC")) {
return keyType === "ECDSA";
}
return false;
}) || [];
if (compatibleAlgorithms.length === 0) {
throw new BadRequestError({
message: `Template signature algorithms (${template.algorithms?.signature?.join(", ") || "none"}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
});
}
};
const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => {
if (typeof certData === "string") return certData;
if (Buffer.isBuffer(certData)) return bufferToString(certData);
if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) {
return bufferToString(certData.rawData);
}
return bufferToString(certData as unknown as Buffer);
};
export const certificateV3ServiceFactory = ({
certificateDAL,
certificateAuthorityDAL,
certificateProfileDAL,
certificateTemplateV2Service,
internalCaService,
permissionService
}: TCertificateV3ServiceFactoryDep) => {
const issueCertificateFromProfile = async ({
profileId,
certificateRequest,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TIssueCertificateFromProfileDTO): Promise<TCertificateFromProfileResponse> => {
const profile = await validateProfileAndPermissions(
profileId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
certificateProfileDAL,
permissionService,
EnrollmentType.API
);
if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) {
throw new BadRequestError({
message: "Common Name must be a single value, not an array"
});
}
const mappedCertificateRequest = mapEnumsForValidation({
...certificateRequest,
subjectAlternativeNames: certificateRequest.altNames
});
const template = await certificateTemplateV2Service.getTemplateV2ById({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId
});
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
}
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
);
if (!validationResult.isValid) {
throw new BadRequestError({
message: `Certificate request validation failed: ${validationResult.errors.join(", ")}`
});
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
validateCaSupport(ca, "direct certificate issuance");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate issuance" });
}
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined;
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request"
});
}
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject);
const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
{ subjectAlternativeNames: certificateRequest.altNames },
template.sans
);
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
await internalCaService.issueCertFromCa({
caId: ca.id,
friendlyName: certificateSubject.common_name || "Certificate",
commonName: certificateSubject.common_name || "",
altNames: subjectAlternativeNames,
ttl: certificateRequest.validity.ttl,
keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [],
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [],
notBefore: normalizeDateForApi(certificateRequest.notBefore),
notAfter: normalizeDateForApi(certificateRequest.notAfter),
signatureAlgorithm: effectiveSignatureAlgorithm,
keyAlgorithm: effectiveKeyAlgorithm,
actor,
actorId,
actorAuthMethod,
actorOrgId,
isFromProfile: true
});
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
if (!cert) {
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
}
await certificateDAL.updateById(cert.id, { profileId });
return {
certificate: bufferToString(certificate),
issuingCaCertificate: bufferToString(issuingCaCertificate),
certificateChain: bufferToString(certificateChain),
privateKey: bufferToString(privateKey),
serialNumber,
certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
};
};
const signCertificateFromProfile = async ({
profileId,
csr,
validity,
notBefore,
notAfter,
signatureAlgorithm,
keyAlgorithm,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TSignCertificateFromProfileDTO): Promise<Omit<TCertificateFromProfileResponse, "privateKey">> => {
const profile = await validateProfileAndPermissions(
profileId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
certificateProfileDAL,
permissionService,
EnrollmentType.API
);
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
validateCaSupport(ca, "CSR signing");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate signing" });
}
const template = await certificateTemplateV2Service.getTemplateV2ById({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId
});
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
}
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm;
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request"
});
}
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
await internalCaService.signCertFromCa({
isInternal: true,
caId: ca.id,
csr,
ttl: validity.ttl,
altNames: undefined,
notBefore: normalizeDateForApi(notBefore),
notAfter: normalizeDateForApi(notAfter),
signatureAlgorithm: effectiveSignatureAlgorithm,
keyAlgorithm: effectiveKeyAlgorithm,
isFromProfile: true
});
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
if (!cert) {
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
}
await certificateDAL.updateById(cert.id, { profileId });
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
return {
certificate: certificateString,
issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer),
certificateChain: certificateChainString,
serialNumber,
certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
};
};
const orderCertificateFromProfile = async ({
profileId,
certificateOrder,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
const profile = await validateProfileAndPermissions(
profileId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
certificateProfileDAL,
permissionService,
EnrollmentType.API
);
const certificateRequest = {
commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS,
value: san.value
})),
validity: certificateOrder.validity,
notBefore: certificateOrder.notBefore,
notAfter: certificateOrder.notAfter,
signatureAlgorithm: certificateOrder.signatureAlgorithm,
keyAlgorithm: certificateOrder.keyAlgorithm
};
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
);
if (!validationResult.isValid) {
throw new BadRequestError({
message: `Certificate order validation failed: ${validationResult.errors.join(", ")}`
});
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType === CaType.INTERNAL) {
const certificateResult = await issueCertificateFromProfile({
profileId,
certificateRequest,
actor,
actorId,
actorAuthMethod,
actorOrgId
});
const orderId = randomUUID();
return {
orderId,
status: CertificateOrderStatus.VALID,
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
type: san.type,
value: san.value,
status: CertificateOrderStatus.VALID
})),
authorizations: [],
finalize: `/api/v3/certificates/orders/${orderId}/completed`,
certificate: certificateResult.certificate,
projectId: certificateResult.projectId,
profileName: certificateResult.profileName
};
}
if (caType === CaType.ACME) {
throw new BadRequestError({
message: "ACME certificate ordering via profiles is not yet implemented."
});
}
throw new BadRequestError({
message: `Certificate ordering is not supported for CA type: ${caType}`
});
};
return {
issueCertificateFromProfile,
signCertificateFromProfile,
orderCertificateFromProfile
};
};

View File

@@ -0,0 +1,99 @@
import { TProjectPermission } from "@app/lib/types";
import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType
} from "../certificate-common/certificate-constants";
export type TIssueCertificateFromProfileDTO = {
profileId: string;
certificateRequest: {
commonName?: string;
keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsageType[];
altNames?: Array<{
type: CertSubjectAlternativeNameType;
value: string;
}>;
validity: {
ttl: string;
};
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
} & Omit<TProjectPermission, "projectId">;
export type TSignCertificateFromProfileDTO = {
profileId: string;
csr: string;
validity: {
ttl: string;
};
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">;
export type TOrderCertificateFromProfileDTO = {
profileId: string;
certificateOrder: {
altNames: Array<{
type: ACMESANType;
value: string;
}>;
validity: {
ttl: string;
};
commonName?: string;
keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsageType[];
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
} & Omit<TProjectPermission, "projectId">;
export type TCertificateFromProfileResponse = {
certificate: string;
issuingCaCertificate: string;
certificateChain: string;
privateKey?: string;
serialNumber: string;
certificateId: string;
projectId: string;
profileName: string;
};
export type TCertificateOrderResponse = {
orderId: string;
status: CertificateOrderStatus;
subjectAlternativeNames: Array<{
type: ACMESANType;
value: string;
status: CertificateOrderStatus;
}>;
authorizations: Array<{
identifier: {
type: ACMESANType;
value: string;
};
status: CertificateOrderStatus;
expires?: string;
challenges: Array<{
type: string;
status: CertificateOrderStatus;
url: string;
token: string;
}>;
}>;
finalize: string;
certificate?: string;
projectId: string;
profileName: string;
};

View File

@@ -8,14 +8,26 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
export enum CertStatus {
ACTIVE = "active",
EXPIRED = "expired",
REVOKED = "revoked"
}
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
ECDSA_P384 = "EC_secp384r1",
ECDSA_P521 = "EC_secp521r1"
}
export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384",
RSA_SHA512 = "RSA-SHA512",
ECDSA_SHA256 = "ECDSA-SHA256",
ECDSA_SHA384 = "ECDSA-SHA384",
ECDSA_SHA512 = "ECDSA-SHA512"
}
export enum CertKeyUsage {
@@ -39,6 +51,11 @@ export enum CertExtendedKeyUsage {
OCSP_SIGNING = "ocspSigning"
}
export enum CertSignatureType {
RSA = "RSA",
ECDSA = "ECDSA"
}
export const CertExtendedKeyUsageOIDToName: Record<string, CertExtendedKeyUsage> = {
[x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH,
[x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH,
@@ -105,13 +122,48 @@ export type TGetCertificateCredentialsDTO = {
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum TAltNameType {
EMAIL = "email",
DNS = "dns",
IP = "ip",
URL = "url"
}
export const mapLegacyAltNameType = (legacyType: TAltNameType): CertSubjectAlternativeNameType => {
switch (legacyType) {
case TAltNameType.EMAIL:
return CertSubjectAlternativeNameType.EMAIL;
case TAltNameType.DNS:
return CertSubjectAlternativeNameType.DNS_NAME;
case TAltNameType.IP:
return CertSubjectAlternativeNameType.IP_ADDRESS;
case TAltNameType.URL:
return CertSubjectAlternativeNameType.URI;
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unknown legacy alt name type: ${legacyType}`);
}
};
export type TAltNameMapping = {
type: TAltNameType;
value: string;
};
export enum ACMESANType {
DNS = "dns",
IP = "ip"
}
export enum CertificateOrderStatus {
PENDING = "pending",
PROCESSING = "processing",
VALID = "valid",
INVALID = "invalid"
}

View File

@@ -0,0 +1,118 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types";
export type TApiEnrollmentConfigDALFactory = ReturnType<typeof apiEnrollmentConfigDALFactory>;
export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig);
const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => {
try {
const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*");
return apiConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Create API enrollment config" });
}
};
const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => {
try {
const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*");
return apiConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Update API enrollment config" });
}
};
const deleteById = async (id: string, tx?: Knex) => {
try {
const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*");
return apiConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Delete API enrollment config" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first();
return apiConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Find API enrollment config by id" });
}
};
const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, projectId?: string, tx?: Knex) => {
try {
let query = (tx || db)(TableName.PkiCertificateProfile)
.join(
TableName.PkiApiEnrollmentConfig,
`${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id`
)
.where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true);
if (projectId) {
query = query.where(`${TableName.PkiCertificateProfile}.projectId`, projectId);
}
const profiles = await query
.where((qb) => {
void qb
.whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`)
.orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
})
.select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile));
return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>;
} catch (error) {
throw new DatabaseError({ error, name: "Find profiles for auto renewal" });
}
};
const isConfigInUse = async (configId: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.PkiCertificateProfile).where({ apiConfigId: configId }).count("*").first();
if (!doc || typeof doc !== "object") {
return 0;
}
const countValue = (doc as Record<string, unknown>).count;
if (typeof countValue === "number") {
return countValue;
}
if (typeof countValue === "string") {
const parsed = parseInt(countValue, 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
} catch (error) {
throw new DatabaseError({ error, name: "Check if API enrollment config is in use" });
}
};
return {
...apiEnrollmentConfigOrm,
create,
updateById,
deleteById,
findById,
findProfilesForAutoRenewal,
isConfigInUse
};
};

View File

@@ -0,0 +1,29 @@
import {
TPkiApiEnrollmentConfigs,
TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate
} from "@app/db/schemas/pki-api-enrollment-configs";
import {
TPkiEstEnrollmentConfigs,
TPkiEstEnrollmentConfigsInsert,
TPkiEstEnrollmentConfigsUpdate
} from "@app/db/schemas/pki-est-enrollment-configs";
export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs;
export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert;
export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate;
export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs;
export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert;
export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
export interface TEstConfigData {
disableBootstrapCaValidation: boolean;
passphrase: string;
caChain?: string;
}
export interface TApiConfigData {
autoRenew: boolean;
autoRenewDays?: number;
}

View File

@@ -0,0 +1,61 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types";
export type TEstEnrollmentConfigDALFactory = ReturnType<typeof estEnrollmentConfigDALFactory>;
export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig);
const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*");
const [estConfig] = result;
if (!estConfig) {
throw new Error("Failed to create EST enrollment config");
}
return estConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Create EST enrollment config" });
}
};
const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*");
const [estConfig] = result;
if (!estConfig) {
return null;
}
return estConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Update EST enrollment config" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first();
return estConfig || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find EST enrollment config by id" });
}
};
return {
...estEnrollmentConfigOrm,
create,
updateById,
findById
};
};

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/pki/certificate-profiles"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/pki/certificate-profiles/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/pki/certificate-profiles/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by Slug"
openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List Certificates"
openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/pki/certificate-profiles"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/pki/certificate-profiles/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v2/certificate-templates"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v2/certificate-templates/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v2/certificate-templates/{id}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/certificate-templates"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v2/certificate-templates/{id}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Create"
openapi: "POST /api/v1/pki/certificate-templates"
---

View File

@@ -1,4 +0,0 @@
---
title: "Delete"
openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Get by ID"
openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Update"
openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Create"
openapi: "POST /api/v1/pki/subscribers"
---

View File

@@ -1,4 +0,0 @@
---
title: "Delete"
openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Retrieve latest certificate bundle"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle"
---

View File

@@ -1,4 +0,0 @@
---
title: "Issue Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate"
---

View File

@@ -1,4 +0,0 @@
---
title: "List Certificates"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates"
---

View File

@@ -1,4 +0,0 @@
---
title: "Order Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate"
---

View File

@@ -1,4 +0,0 @@
---
title: "Retrieve"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}"
---

View File

@@ -1,4 +0,0 @@
---
title: "Sign Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate"
---

View File

@@ -1,4 +0,0 @@
---
title: "Update"
openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}"
---

View File

@@ -2452,20 +2452,6 @@
{
"group": "Infisical PKI",
"pages": [
{
"group": "Subscribers",
"pages": [
"api-reference/endpoints/pki/subscribers/list-certs",
"api-reference/endpoints/pki/subscribers/create",
"api-reference/endpoints/pki/subscribers/read",
"api-reference/endpoints/pki/subscribers/update",
"api-reference/endpoints/pki/subscribers/delete",
"api-reference/endpoints/pki/subscribers/issue-cert",
"api-reference/endpoints/pki/subscribers/sign-cert",
"api-reference/endpoints/pki/subscribers/order-cert",
"api-reference/endpoints/pki/subscribers/get-latest-cert-bundle"
]
},
{
"group": "Certificate Authorities",
"pages": [
@@ -2522,10 +2508,11 @@
{
"group": "Certificate Templates",
"pages": [
"api-reference/endpoints/certificate-templates/create",
"api-reference/endpoints/certificate-templates/update",
"api-reference/endpoints/certificate-templates/get-by-id",
"api-reference/endpoints/certificate-templates/delete"
"api-reference/endpoints/certificate-templates-v2/list",
"api-reference/endpoints/certificate-templates-v2/create",
"api-reference/endpoints/certificate-templates-v2/update",
"api-reference/endpoints/certificate-templates-v2/get-by-id",
"api-reference/endpoints/certificate-templates-v2/delete"
]
},
{
@@ -2549,6 +2536,15 @@
"api-reference/endpoints/pki-alerts/delete"
]
},
{
"group": "Certificate Profiles",
"pages": [
"api-reference/endpoints/certificate-profiles/create",
"api-reference/endpoints/certificate-profiles/update",
"api-reference/endpoints/certificate-profiles/get-by-id",
"api-reference/endpoints/certificate-profiles/delete"
]
},
{
"group": "Certificate Syncs",
"pages": [

View File

@@ -291,6 +291,16 @@ Supports conditions and permission inversion
| `create` | Issue new certificates |
| `delete` | Revoke or remove certificates |
#### Subject: `certificate-profiles`
| Action | Description |
| -------- | -------------------------------- |
| `read` | View certificate profiles |
| `create` | Create new certificate profiles |
| `edit` | Modify profile configurations |
| `delete` | Remove certificate profiles |
| `issue-cert` | Issue new certificates |
#### Subject: `certificate-templates`
| Action | Description |

View File

@@ -4,6 +4,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,

View File

@@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions {
ListCerts = "list-certs"
}
export enum ProjectPermissionCertificateProfileActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
@@ -293,6 +301,7 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
PkiSubscribers = "pki-subscribers",
CertificateProfiles = "certificate-profiles",
Kms = "kms",
Cmek = "cmek",
SecretSyncs = "secret-syncs",
@@ -470,6 +479,7 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.PkiSubscribers> & PkiSubscriberSubjectFields)
)
]
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]

View File

@@ -15,6 +15,7 @@ export {
ProjectPermissionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions,

View File

@@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
case ProjectType.SecretManager:
return "/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers" as const;
return "/projects/cert-management/$projectId/policies" as const;
case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM:

View File

@@ -2,8 +2,10 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from
export {
useCreateCa,
useCreateCertificate,
useCreateCertificateV3,
useDeleteCa,
useImportCaCertificate,
useOrderCertificateWithProfile,
useRenewCa,
useSignIntermediate,
useUpdateCa
@@ -21,3 +23,4 @@ export {
useListCasByTypeAndProjectId,
useListExternalCasByProjectId
} from "./queries";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";

View File

@@ -9,9 +9,13 @@ import {
TCreateCertificateAuthorityDTO,
TCreateCertificateDTO,
TCreateCertificateResponse,
TCreateCertificateV3DTO,
TCreateCertificateV3Response,
TDeleteCertificateAuthorityDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
TOrderCertificateDTO,
TOrderCertificateResponse,
TRenewCaDTO,
TRenewCaResponse,
TSignIntermediateDTO,
@@ -148,6 +152,46 @@ export const useCreateCertificate = () => {
});
};
export const useCreateCertificateV3 = () => {
const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TCreateCertificateV3Response>(
"/api/v3/certificates/issue-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});
}
});
};
export const useOrderCertificateWithProfile = () => {
const queryClient = useQueryClient();
return useMutation<TOrderCertificateResponse, object, TOrderCertificateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TOrderCertificateResponse>(
"/api/v3/certificates/order-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};
export const useRenewCa = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCaResponse, object, TRenewCaDTO>({

View File

@@ -155,7 +155,7 @@ export type TCreateCertificateDTO = {
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
altNames: string; // sans
subjectAltNames: string; // sans
ttl: string; // string compatible with ms
notBefore?: string;
notAfter?: string;
@@ -171,6 +171,84 @@ export type TCreateCertificateResponse = {
serialNumber: string;
};
export type TCreateCertificateV3DTO = {
projectSlug: string;
profileId: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
organization?: string;
organizationUnit?: string;
locality?: string;
state?: string;
country?: string;
email?: string;
streetAddress?: string;
postalCode?: string;
subjectAltNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TCreateCertificateV3Response = TCreateCertificateResponse & {
projectId: string;
profileName: string;
certificateId: string;
};
export type TOrderCertificateDTO = {
projectSlug: string;
profileId: string;
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
}>;
ttl: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
notBefore?: string;
notAfter?: string;
commonName?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TOrderCertificateResponse = {
orderId: string;
status: "pending" | "processing" | "valid" | "invalid";
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
status: "pending" | "processing" | "valid" | "invalid";
}>;
authorizations: Array<{
identifier: {
type: "dns" | "ip";
value: string;
};
status: "pending" | "processing" | "valid" | "invalid";
expires?: string;
challenges: Array<{
type: string;
status: "pending" | "processing" | "valid" | "invalid";
url: string;
token: string;
validated?: string;
error?: string | Error;
}>;
}>;
certificate?: string;
privateKey?: string;
expires: string;
notBefore: string;
notAfter: string;
};
export type TRenewCaDTO = {
projectSlug: string;
caId: string;

View File

@@ -0,0 +1,14 @@
export {
useCreateCertificateProfile,
useDeleteCertificateProfile,
useUpdateCertificateProfile
} from "./mutations";
export {
certificateProfileKeys,
useGetCertificateProfileById,
useGetCertificateProfileBySlug,
useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles
} from "./queries";
export type * from "./types";

View File

@@ -0,0 +1,71 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { certificateProfileKeys } from "./queries";
import {
TCertificateProfile,
TCreateCertificateProfileDTO,
TDeleteCertificateProfileDTO,
TUpdateCertificateProfileDTO
} from "./types";
export const useCreateCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TCreateCertificateProfileDTO>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<{
certificateProfile: TCertificateProfile;
}>("/api/v1/pki/certificate-profiles", data);
return response.certificateProfile;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId })
});
}
});
};
export const useUpdateCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TUpdateCertificateProfileDTO>({
mutationFn: async ({ profileId, ...data }) => {
const { data: response } = await apiRequest.patch<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/${profileId}`, data);
return response.certificateProfile;
},
onSuccess: (profile, { profileId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
});
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.getById(profileId)
});
}
});
};
export const useDeleteCertificateProfile = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateProfile, object, TDeleteCertificateProfileDTO>({
mutationFn: async ({ profileId }) => {
const { data: response } = await apiRequest.delete<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/${profileId}`);
return response.certificateProfile;
},
onSuccess: (profile, { profileId }) => {
queryClient.invalidateQueries({
queryKey: certificateProfileKeys.list({ projectId: profile.projectId })
});
queryClient.removeQueries({
queryKey: certificateProfileKeys.getById(profileId)
});
}
});
};

View File

@@ -0,0 +1,162 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO,
TGetProfileCertificatesDTO,
TGetProfileMetricsDTO,
TListCertificateProfilesDTO,
TProfileCertificate
} from "./types";
export const certificateProfileKeys = {
list: (params: {
projectId: string;
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: string;
expiringDays?: number;
}) => ["certificate-profiles", "list", params],
getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId],
getBySlug: (projectId: string, slug: string) => [
"certificate-profiles",
"get-by-slug",
projectId,
slug
],
getCertificates: (profileId: string, params?: Omit<TGetProfileCertificatesDTO, "profileId">) => [
"certificate-profiles",
"certificates",
profileId,
params
],
getMetrics: (profileId: string, params?: Omit<TGetProfileMetricsDTO, "profileId">) => [
"certificate-profiles",
"metrics",
profileId,
params
]
};
export const useListCertificateProfiles = ({
projectId,
limit = 20,
offset = 0,
search,
includeMetrics = false,
includeConfigs = false,
enrollmentType,
expiringDays = 7
}: TListCertificateProfilesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.list({
projectId,
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
}),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfiles: TCertificateProfile[];
totalCount: number;
}>("/api/v1/pki/certificate-profiles", {
params: {
projectId,
limit,
offset,
search,
includeMetrics,
includeConfigs,
enrollmentType,
expiringDays
}
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getById(profileId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfile: TCertificateProfileWithDetails;
}>(`/api/v1/pki/certificate-profiles/${profileId}`);
return data.certificateProfile;
},
enabled: Boolean(profileId)
});
};
export const useGetCertificateProfileBySlug = ({
projectId,
slug
}: TGetCertificateProfileBySlugDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getBySlug(projectId, slug),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateProfile: TCertificateProfile;
}>(`/api/v1/pki/certificate-profiles/slug/${slug}`, {
params: { projectId }
});
return data.certificateProfile;
},
enabled: Boolean(projectId && slug)
});
};
export const useGetProfileCertificates = ({
profileId,
offset = 0,
limit = 20,
status,
search
}: TGetProfileCertificatesDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificates: TProfileCertificate[];
}>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, {
params: {
offset,
limit,
status,
search
}
});
return data.certificates;
},
enabled: Boolean(profileId)
});
};
export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }),
queryFn: async () => {
const { data } = await apiRequest.get<{
metrics: TCertificateProfileMetrics;
}>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, {
params: { expiringDays }
});
return data.metrics;
},
enabled: Boolean(profileId)
});
};

View File

@@ -0,0 +1,130 @@
export type TCertificateProfile = {
id: string;
projectId: string;
caId: string;
certificateTemplateId: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
estConfigId?: string;
apiConfigId?: string;
createdAt: string;
updatedAt: string;
metrics?: TCertificateProfileMetrics;
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateAuthority?: {
id: string;
projectId: string;
status: string;
name: string;
};
certificateTemplate?: {
id: string;
projectId: string;
name: string;
description?: string;
};
estConfig?: {
id: string;
disableBootstrapCaValidation: boolean;
passphrase: string;
caChain: string;
};
apiConfig?: {
id: string;
autoRenew: boolean;
autoRenewDays?: number;
};
};
export type TCreateCertificateProfileDTO = {
projectId: string;
caId: string;
certificateTemplateId: string;
slug: string;
description?: string;
enrollmentType: "api" | "est";
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
};
export type TUpdateCertificateProfileDTO = {
profileId: string;
slug?: string;
description?: string;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;
caChain?: string;
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
};
};
export type TDeleteCertificateProfileDTO = {
profileId: string;
};
export type TListCertificateProfilesDTO = {
projectId: string;
limit?: number;
offset?: number;
search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean;
enrollmentType?: "api" | "est";
expiringDays?: number;
};
export type TGetCertificateProfileByIdDTO = {
profileId: string;
};
export type TGetCertificateProfileBySlugDTO = {
projectId: string;
slug: string;
};
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = {
id: string;
serialNumber: string;
cn: string;
status: string;
notBefore: string;
notAfter: string;
isRevoked: boolean;
createdAt: string;
};
export type TGetProfileCertificatesDTO = {
profileId: string;
offset?: number;
limit?: number;
status?: "active" | "expired" | "revoked";
search?: string;
};
export type TGetProfileMetricsDTO = {
profileId: string;
expiringDays?: number;
};

View File

@@ -7,13 +7,17 @@ import { projectKeys } from "../projects";
import { certTemplateKeys } from "./queries";
import {
TCertificateTemplate,
TCertificateTemplateV2WithPolicies,
TCreateCertificateTemplateDTO,
TCreateCertificateTemplateV2DTO,
TCreateCertificateTemplateV2WithPoliciesDTO,
TCreateEstConfigDTO,
TDeleteCertificateTemplateDTO,
TDeleteCertificateTemplateV2DTO,
TDeleteCertificateTemplateV2WithPoliciesDTO,
TUpdateCertificateTemplateDTO,
TUpdateCertificateTemplateV2DTO,
TUpdateCertificateTemplateV2WithPoliciesDTO,
TUpdateEstConfigDTO
} from "./types";
@@ -163,3 +167,72 @@ export const useUpdateEstConfig = () => {
}
});
};
export const useCreateCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TCreateCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async (data) => {
const { data: response } = await apiRequest.post<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>("/api/v2/certificate-templates", data);
return response.certificateTemplate;
},
onSuccess: (_, { projectId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId })
});
}
});
};
export const useUpdateCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TUpdateCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async ({ templateId, ...data }) => {
const { data: response } = await apiRequest.patch<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`, data);
return response.certificateTemplate;
},
onSuccess: (template, { templateId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
});
queryClient.invalidateQueries({
queryKey: certTemplateKeys.getTemplateV2ById(templateId)
});
}
});
};
export const useDeleteCertificateTemplateV2WithPolicies = () => {
const queryClient = useQueryClient();
return useMutation<
TCertificateTemplateV2WithPolicies,
object,
TDeleteCertificateTemplateV2WithPoliciesDTO
>({
mutationFn: async ({ templateId }) => {
const { data: response } = await apiRequest.delete<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`);
return response.certificateTemplate;
},
onSuccess: (template, { templateId }) => {
queryClient.invalidateQueries({
queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId })
});
queryClient.removeQueries({
queryKey: certTemplateKeys.getTemplateV2ById(templateId)
});
}
});
};

View File

@@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request";
import {
TCertificateTemplate,
TCertificateTemplateV2,
TCertificateTemplateV2WithPolicies,
TEstConfig,
TListCertificateTemplatesDTO
TGetCertificateTemplateV2ByIdDTO,
TListCertificateTemplatesDTO,
TListCertificateTemplatesV2DTO
} from "./types";
export const certTemplateKeys = {
@@ -16,7 +19,16 @@ export const certTemplateKeys = {
projectId,
el
],
getEstConfig: (id: string) => [{ id }, "cert-template-est-config"]
getEstConfig: (id: string) => [{ id }, "cert-template-est-config"],
listTemplatesV2: ({
projectId,
...el
}: {
limit?: number;
offset?: number;
projectId: string;
}) => ["list-templates-v2", projectId, el],
getTemplateV2ById: (id: string) => ["cert-template-v2", id]
};
export const useGetCertTemplate = (id: string) => {
@@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => {
enabled: Boolean(certificateTemplateId)
});
};
export const useListCertificateTemplatesV2 = ({
projectId,
limit = 20,
offset = 0
}: TListCertificateTemplatesV2DTO) => {
return useQuery({
queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateTemplates: TCertificateTemplateV2WithPolicies[];
totalCount: number;
}>("/api/v2/certificate-templates", {
params: {
projectId,
limit,
offset
}
});
return data;
},
enabled: Boolean(projectId)
});
};
export const useGetCertificateTemplateV2ById = ({
templateId
}: TGetCertificateTemplateV2ByIdDTO) => {
return useQuery({
queryKey: certTemplateKeys.getTemplateV2ById(templateId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateTemplate: TCertificateTemplateV2WithPolicies;
}>(`/api/v2/certificate-templates/${templateId}`);
return data.certificateTemplate;
},
enabled: Boolean(templateId)
});
};

View File

@@ -121,3 +121,90 @@ export type TListCertificateTemplatesDTO = {
offset?: number;
projectId: string;
};
export type TCertificateTemplateV2Policy = {
subject?: Array<{
type: "common_name" | "organization" | "country";
allowed?: string[];
required?: string[];
denied?: string[];
}>;
sans?: Array<{
type: "dns_name" | "ip_address" | "email" | "uri";
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
extendedKeyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
algorithms?: {
signature?: Array<
"SHA256-RSA" | "SHA384-RSA" | "SHA512-RSA" | "SHA256-ECDSA" | "SHA384-ECDSA" | "SHA512-ECDSA"
>;
keyAlgorithm?: Array<"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384">;
};
validity?: {
max?: string;
};
};
export type TCertificateTemplateV2WithPolicies = {
id: string;
projectId: string;
name: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
createdAt: string;
updatedAt: string;
};
export type TCreateCertificateTemplateV2WithPoliciesDTO = {
projectId: string;
name: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
};
export type TUpdateCertificateTemplateV2WithPoliciesDTO = {
templateId: string;
name?: string;
description?: string;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
};
export type TDeleteCertificateTemplateV2WithPoliciesDTO = {
templateId: string;
};
export type TListCertificateTemplatesV2DTO = {
projectId: string;
limit?: number;
offset?: number;
};
export type TGetCertificateTemplateV2ByIdDTO = {
templateId: string;
};

View File

@@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => {
export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
[CertKeyAlgorithm.RSA_2048]: "RSA 2048",
[CertKeyAlgorithm.RSA_3072]: "RSA 3072",
[CertKeyAlgorithm.RSA_4096]: "RSA 4096",
[CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256",
[CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384"
@@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = {
export const certKeyAlgorithms = [
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 },
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 },
{ label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 },
{
label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256],
@@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [
{ value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" },
{ value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" }
] as const;
export const SIGNATURE_ALGORITHMS_OPTIONS = [
{ value: "RSA-SHA256", label: "RSA-SHA256" },
{ value: "RSA-SHA384", label: "RSA-SHA384" },
{ value: "RSA-SHA512", label: "RSA-SHA512" },
{ value: "ECDSA-SHA256", label: "ECDSA-SHA256" },
{ value: "ECDSA-SHA384", label: "ECDSA-SHA384" },
{ value: "ECDSA-SHA512", label: "ECDSA-SHA512" }
] as const;

View File

@@ -5,6 +5,7 @@ export enum CertStatus {
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_3072 = "RSA_3072",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
@@ -24,22 +25,22 @@ export enum CrlReason {
}
export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment",
NON_REPUDIATION = "nonRepudiation",
DATA_ENCIPHERMENT = "dataEncipherment",
KEY_AGREEMENT = "keyAgreement",
KEY_CERT_SIGN = "keyCertSign",
CRL_SIGN = "cRLSign",
ENCIPHER_ONLY = "encipherOnly",
DECIPHER_ONLY = "decipherOnly"
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth",
SERVER_AUTH = "serverAuth",
CODE_SIGNING = "codeSigning",
EMAIL_PROTECTION = "emailProtection",
TIMESTAMPING = "timeStamping",
OCSP_SIGNING = "ocspSigning"
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
TIMESTAMPING = "time_stamping",
OCSP_SIGNING = "ocsp_signing"
}

View File

@@ -52,6 +52,10 @@ export const useRevokeCert = () => {
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
}
});
};

View File

@@ -7,7 +7,7 @@ export type TCertificate = {
status: CertStatus;
friendlyName: string;
commonName: string;
altNames: string;
subjectAltNames: string;
serialNumber: string;
notBefore: string;
notAfter: string;

View File

@@ -47,6 +47,7 @@ export type SubscriptionPlan = {
gateway: boolean;
externalKms: boolean;
pkiEst: boolean;
pkiLegacyTemplates: boolean;
enforceMfa: boolean;
enforceGoogleSSO: boolean;
projectTemplates: boolean;

Some files were not shown because too many files have changed in this diff Show More