PKI revamp, last changes and improvements on API and UI

This commit is contained in:
Carlos Monastyrski
2025-10-16 12:29:12 -03:00
parent 42800fdfe5
commit 5684127ce0
28 changed files with 2482 additions and 2257 deletions
@@ -10,20 +10,19 @@ export async function up(knex: Knex): Promise<void> {
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project);
t.string("slug").notNullable();
t.string("name").notNullable();
t.string("description");
t.jsonb("attributes");
t.jsonb("subject");
t.jsonb("sans");
t.jsonb("keyUsages");
t.jsonb("extendedKeyUsages");
t.jsonb("subjectAlternativeNames");
t.jsonb("algorithms");
t.jsonb("validity");
t.jsonb("signatureAlgorithm");
t.jsonb("keyAlgorithm");
t.timestamps(true, true, true);
t.unique(["slug", "projectId"]);
t.unique(["name", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
@@ -10,15 +10,14 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplatesV2Schema = z.object({
id: z.string().uuid(),
projectId: z.string(),
slug: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
attributes: z.unknown().nullable().optional(),
subject: z.unknown().nullable().optional(),
sans: z.unknown().nullable().optional(),
keyUsages: z.unknown().nullable().optional(),
extendedKeyUsages: z.unknown().nullable().optional(),
subjectAlternativeNames: z.unknown().nullable().optional(),
algorithms: z.unknown().nullable().optional(),
validity: z.unknown().nullable().optional(),
signatureAlgorithm: z.unknown().nullable().optional(),
keyAlgorithm: z.unknown().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
+1 -1
View File
@@ -11,8 +11,8 @@ 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 { registerCertRouter } from "./certificate-router";
import { registerCertificateProfilesRouter } from "./certificate-profiles-router";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateTemplateRouter } from "./certificate-template-router";
import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";
@@ -1,110 +1,133 @@
import { z } from "zod";
import { CertificateTemplatesV2Schema } 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 { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
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()
.regex(/^\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()).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) => {
const templateV2AttributeSchema = z
.object({
type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.type === CertSubjectAttributeType.COMMON_NAME && data.value && data.value.length > 1) {
return false;
}
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
}
);
const templateV2KeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
});
const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
});
const templateV2SanSchema = z
.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Mandatory SANs can only have one value or no value (empty)"
}
);
const templateV2ValiditySchema = z.object({
maxDuration: z.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
}),
minDuration: z
.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
})
.optional()
});
const templateV2SignatureAlgorithmSchema = z.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
});
const templateV2KeyAlgorithmSchema = z.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
});
server.route({
method: "POST",
url: "/",
@@ -114,39 +137,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
body: z
.object({
projectId: z.string().min(1),
slug: slugSchema({ min: 1, max: 255 }),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
})
.refine(
(data) => {
const hasConstraints =
(data.attributes && data.attributes.length > 0) ||
(data.subjectAlternativeNames && data.subjectAlternativeNames.length > 0) ||
data.keyUsages ||
data.extendedKeyUsages ||
data.validity ||
data.signatureAlgorithm ||
data.keyAlgorithm;
return hasConstraints;
},
{
message:
"Certificate template must define at least one constraint (attributes, SANs, key usages, validity, or algorithms)"
}
),
body: createCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -169,7 +163,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug,
name: certificateTemplate.name,
projectId: certificateTemplate.projectId
}
}
@@ -196,7 +190,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplates: CertificateTemplatesV2Schema.array(),
certificateTemplates: certificateTemplateV2ResponseSchema.array(),
totalCount: z.number()
})
}
@@ -240,7 +234,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -261,7 +255,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});
@@ -282,20 +276,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
params: z.object({
id: z.string().uuid()
}),
body: z.object({
slug: slugSchema({ min: 1, max: 255 }).optional(),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
}),
body: updateCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -317,7 +301,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});
@@ -340,7 +324,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -361,7 +345,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});
@@ -32,6 +32,17 @@ export enum CertIncludeType {
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",
@@ -39,7 +50,9 @@ export enum CertDurationUnit {
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
}
export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => {
@@ -184,3 +197,5 @@ 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);
@@ -50,43 +50,27 @@ export const buildCertificateSubjectFromTemplate = (
request: Record<string, unknown>,
templateAttributes?: Array<{
type: string;
include: "mandatory" | "optional" | "prohibit";
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>
): Record<string, string | undefined> => {
const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = {
common_name: "commonName"
common_name: "commonName",
organization: "organization",
country: "country"
};
if (!templateAttributes || templateAttributes.length === 0) {
throw new Error(
"Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints."
);
return subject;
}
const allowedAttributes = new Set(templateAttributes.map((attr) => attributeMap[attr.type]));
Object.keys(attributeMap).forEach((templateType) => {
const requestKey = attributeMap[templateType];
const value = request[requestKey];
if (value && !allowedAttributes.has(requestKey)) {
throw new Error(
`Certificate attribute '${requestKey}' is not allowed by the template. Template must define constraints for all requested attributes.`
);
}
});
templateAttributes.forEach((attr) => {
if (attr.include === "prohibit") {
return;
}
const requestKey = attributeMap[attr.type];
const value = request[requestKey];
if (value && typeof value === "string") {
if (value && typeof value === "string" && (attr.allowed || attr.required)) {
subject[attr.type] = value;
}
});
@@ -98,8 +82,9 @@ export const buildSubjectAlternativeNamesFromTemplate = (
request: { subjectAlternativeNames?: Array<{ type: string; value: string }> },
templateSans?: Array<{
type: string;
include: "mandatory" | "optional" | "prohibit";
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>
): string => {
if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) {
@@ -107,33 +92,13 @@ export const buildSubjectAlternativeNamesFromTemplate = (
}
if (!templateSans || templateSans.length === 0) {
if (request.subjectAlternativeNames.length > 0) {
throw new Error(
"Template must define allowed subject alternative names. Cannot issue certificate with SANs when template has no SAN constraints."
);
}
return "";
return request.subjectAlternativeNames.map((san) => san.value).join(",");
}
const templateSanTypes = new Set(templateSans.map((san) => san.type));
const prohibitedTypes = new Set(templateSans.filter((san) => san.include === "prohibit").map((san) => san.type));
request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type;
if (!templateSanTypes.has(sanType)) {
throw new Error(
`Subject Alternative Name type '${sanType}' is not allowed by the template. Template must define constraints for all requested SAN types.`
);
}
});
const allowedSans: string[] = [];
request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type;
if (!prohibitedTypes.has(sanType)) {
allowedSans.push(san.value);
}
allowedSans.push(san.value);
});
return allowedSans.join(",");
@@ -68,7 +68,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
(tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
(tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
(tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"),
(tx || db).ref("slug").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
(tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
(tx || db)
@@ -23,19 +23,12 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
const serialized = { ...data } as Record<string, unknown>;
const jsonFields = [
"attributes",
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
jsonFields.forEach((field) => {
const value = (data as Record<string, unknown>)[field];
if (value !== undefined) {
const value = serialized[field];
if (value !== undefined && typeof value !== "string") {
serialized[field] = JSON.stringify(value);
}
});
@@ -44,21 +37,15 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
};
const parseJsonFields = (raw: Record<string, unknown>): TCertificateTemplateV2 => {
const jsonFields = [
"attributes",
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
const parsed = { ...raw };
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
const parsed = { ...raw } as Record<string, unknown>;
jsonFields.forEach((field) => {
const value = raw[field];
if (value) {
if (value !== null && value !== undefined) {
parsed[field] = typeof value === "string" ? JSON.parse(value) : value;
} else {
parsed[field] = undefined;
}
});
@@ -143,7 +130,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -169,7 +156,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -180,10 +167,10 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
}
};
const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => {
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
try {
const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
.where({ slug, projectId })
.where({ name, projectId })
.first();
if (!certificateTemplateV2) {
@@ -192,7 +179,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate template v2 by slug and project id" });
throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" });
}
};
@@ -238,7 +225,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
findById,
findByProjectId,
countByProjectId,
findBySlugAndProjectId,
findByNameAndProjectId,
isTemplateInUse,
getProfilesUsingTemplate
};
@@ -1,166 +1,123 @@
import { z } from "zod";
import { slugSchema } from "@app/server/lib/schemas";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const includeTypeSchema = z.nativeEnum(CertIncludeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const durationUnitSchema = z.nativeEnum(CertDurationUnit);
export const templateV2AttributeSchema = z
const templateV2SubjectSchema = z
.object({
type: attributeTypeSchema,
include: includeTypeSchema,
value: z.array(z.string()).optional()
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.type === "common_name" && data.value && data.value.length > 1) {
return false;
}
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
message: "Subject attribute must have at least one allowed, required, or denied value"
}
);
export const templateV2KeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
});
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"
}
);
export const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
});
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"
}
);
export const templateV2SanSchema = z
const templateV2SanSchema = z
.object({
type: sanTypeSchema,
include: includeTypeSchema,
value: z.array(z.string()).optional()
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Mandatory SANs can only have one value or no value (empty)"
message: "SAN must have at least one allowed, required, or denied value"
}
);
export const templateV2ValiditySchema = z.object({
maxDuration: z.object({
value: z.number().positive(),
unit: durationUnitSchema
}),
minDuration: z
.object({
value: z.number().positive(),
unit: durationUnitSchema
const templateV2ValiditySchema = z.object({
max: z
.string()
.regex(/^\d+[dhmy]$/, {
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
})
.optional()
});
export const templateV2SignatureAlgorithmSchema = z
.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
})
.refine((data) => data.allowedAlgorithms.includes(data.defaultAlgorithm), {
message: "Default signature algorithm must be included in the allowed algorithms list"
});
export const templateV2KeyAlgorithmSchema = z
.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
})
.refine((data) => data.allowedKeyTypes.includes(data.defaultKeyType), {
message: "Default key algorithm must be included in the allowed key types list"
});
export const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1),
slug: slugSchema({ min: 1, max: 255 }),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).min(1),
keyUsages: templateV2KeyUsagesSchema,
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.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()
});
export const updateCertificateTemplateV2Schema = z.object({
slug: slugSchema({ min: 1, max: 255 }).optional(),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
export const certificateTemplateV2ResponseSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
});
export const getCertificateTemplateV2ByIdSchema = z.object({
id: z.string().uuid()
});
export const getCertificateTemplateV2BySlugSchema = z.object({
projectId: z.string().min(1),
slug: slugSchema()
});
export const listCertificateTemplatesV2Schema = 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()
});
export const deleteCertificateTemplateV2Schema = z.object({
id: z.string().uuid()
createdAt: z.date(),
updatedAt: z.date()
});
export const certificateRequestSchema = z.object({
commonName: z.string().optional(),
organization: z.string().optional(),
organizationName: z.string().optional(),
country: z.string().optional(),
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
subjectAlternativeNames: z
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { CertIncludeType, CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
import {
TCertificateRequest,
@@ -57,75 +57,86 @@ export const certificateTemplateV2ServiceFactory = ({
}
};
const convertToMilliseconds = (value: number, unit: "days" | "months" | "years"): number => {
switch (unit) {
case "days":
return value * 24 * 60 * 60 * 1000;
case "months":
return value * 30 * 24 * 60 * 60 * 1000;
case "years":
return value * 365 * 24 * 60 * 60 * 1000;
default:
throw new Error(`Unsupported duration unit: ${unit as string}`);
}
};
const validateSubjectAttributePolicy = (
subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
) => {
if (!subject || subject.length === 0) return;
const validateSubjectAttributePolicy = (attributes: Array<{ type: string; include: string; value?: string[] }>) => {
if (!attributes || attributes.length === 0) return;
const attributesByType = attributes.reduce(
(acc, attr) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push(attr);
return acc;
},
{} as Record<string, typeof attributes>
);
for (const [type, attrs] of Object.entries(attributesByType)) {
const mandatoryAttrs = attrs.filter((attr) => attr.include === CertIncludeType.MANDATORY);
if (mandatoryAttrs.length > 1) {
// 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: `Multiple mandatory values found for subject attribute type '${type}'. Only one mandatory value is allowed per attribute type.`
message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value`
});
}
if (mandatoryAttrs.length === 1 && attrs.length > 1) {
throw new ForbiddenRequestError({
message: `When a mandatory value exists for subject attribute type '${type}', no other values (optional or forbidden) are allowed for that attribute type.`
});
// 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 getRequestAttributeValue = (
request: TCertificateRequest,
attrType: CertSubjectAttributeType | string
): string | undefined => {
switch (attrType) {
case CertSubjectAttributeType.COMMON_NAME:
case "common_name":
return request.commonName;
default:
return undefined;
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 = (baseSlug?: string): string => {
if (baseSlug) {
return slugify(baseSlug);
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.findBySlugAndProjectId(desiredSlug, projectId);
const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId);
if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
return desiredSlug;
}
const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId);
const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId);
if (!existingAlternative) {
return alternativeSlug;
}
@@ -139,8 +150,12 @@ export const certificateTemplateV2ServiceFactory = ({
};
const createWildcardRegex = (pattern: string): RegExp => {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
const regexPattern = escaped.replace(/\*/g, ".*");
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}$`);
};
@@ -166,6 +181,64 @@ export const certificateTemplateV2ServiceFactory = ({
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[],
@@ -184,7 +257,7 @@ export const certificateTemplateV2ServiceFactory = ({
if (regex.test(value)) {
return { isValid: true };
}
} catch {
} catch (error) {
if (allowedValue === value) {
return { isValid: true };
}
@@ -213,89 +286,186 @@ export const certificateTemplateV2ServiceFactory = ({
const errors: string[] = [];
const warnings: string[] = [];
const templateAttributeTypes = new Set(template.attributes?.map((attr) => attr.type) || []);
// 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 || request.organizationName) {
requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization || request.organizationName!);
}
if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country);
const attributePoliciesByType = new Map<string, typeof template.attributes>();
template.attributes?.forEach((attrPolicy) => {
const existing = attributePoliciesByType.get(attrPolicy.type) || [];
attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]);
});
if (subjectPolicies && subjectPolicies.length > 0) {
// Validate each template subject attribute policy
for (const attrPolicy of subjectPolicies) {
const requestValue = requestAttributes.get(attrPolicy.type);
for (const [attrType, policies] of attributePoliciesByType) {
const requestValue = getRequestAttributeValue(request, attrType);
const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY);
const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT);
if (hasProhibit && requestValue) {
errors.push(`${attrType} is prohibited by template policy`);
// eslint-disable-next-line no-continue
continue;
}
if (hasMandatory && !requestValue) {
errors.push(`${attrType} is mandatory but not provided in request`);
// eslint-disable-next-line no-continue
continue;
}
if (requestValue) {
const policiesWithValues = policies.filter(
(p) =>
p.value &&
p.value.length > 0 &&
(p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL)
);
if (policiesWithValues.length > 0) {
const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []);
const validation = validateValueAgainstConstraints(requestValue, allAllowedValues, attrType);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
// Check denied values first
if (requestValue && 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`);
// Skip further validation for this attribute if it's denied
} else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
// Check allowed values if present and not denied
const allowedValidation = validateValueAgainstConstraints(
requestValue,
attrPolicy.allowed,
attrPolicy.type
);
if (!allowedValidation.isValid && allowedValidation.error) {
errors.push(allowedValidation.error);
}
}
} else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
// Check allowed values if present and not denied
const allowedValidation = validateValueAgainstConstraints(requestValue, attrPolicy.allowed, attrPolicy.type);
if (!allowedValidation.isValid && allowedValidation.error) {
errors.push(allowedValidation.error);
}
}
}
}
const requestAttributeTypes: CertSubjectAttributeType[] = [];
if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME);
// Check for required subject attributes
for (const attrPolicy of subjectPolicies) {
if (attrPolicy.required && attrPolicy.required.length > 0) {
const requestValue = requestAttributes.get(attrPolicy.type);
if (!requestValue) {
errors.push(`Missing required ${attrPolicy.type} attribute`);
} else {
// Validate that the request value matches at least one 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(", ")}`
);
}
}
}
}
for (const requestAttrType of requestAttributeTypes) {
if (!templateAttributeTypes.has(requestAttrType)) {
errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`);
// 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)`);
}
}
if (template.keyUsages) {
if (template.keyUsages.requiredUsages && template.keyUsages.requiredUsages.all.length > 0) {
const missingRequired = template.keyUsages.requiredUsages.all.filter(
(usage) => !request.keyUsages?.includes(usage)
);
// 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) {
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(", ")}`);
}
}
if (request.keyUsages && (template.keyUsages.requiredUsages || template.keyUsages.optionalUsages)) {
const allAllowedUsages = [
...(template.keyUsages.requiredUsages?.all || []),
...(template.keyUsages.optionalUsages?.all || [])
];
// 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(", ")}`);
}
}
if (allAllowedUsages.length > 0) {
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid key usages: ${invalidUsages.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)`);
}
if (template.extendedKeyUsages) {
if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) {
const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter(
// 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) {
@@ -303,89 +473,46 @@ export const certificateTemplateV2ServiceFactory = ({
}
}
// 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 &&
(template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages)
extendedKeyUsagePolicy &&
extendedKeyUsagePolicy.allowed &&
extendedKeyUsagePolicy.allowed.length > 0
) {
const allAllowedUsages = [
...(template.extendedKeyUsages.requiredUsages?.all || []),
...(template.extendedKeyUsages.optionalUsages?.all || [])
const allAllowedExtendedUsages = [
...(extendedKeyUsagePolicy.required || []),
...(extendedKeyUsagePolicy.allowed || [])
];
if (allAllowedUsages.length > 0) {
const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`);
}
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)`);
}
const templateSanTypes = new Set(template.subjectAlternativeNames?.map((san) => san.type) || []);
const sanPoliciesByType = new Map<string, typeof template.subjectAlternativeNames>();
template.subjectAlternativeNames?.forEach((sanPolicy) => {
const existing = sanPoliciesByType.get(sanPolicy.type) || [];
sanPoliciesByType.set(sanPolicy.type, [...existing, sanPolicy]);
});
for (const [sanType, policies] of sanPoliciesByType) {
const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanType) || [];
const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY);
const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT);
if (hasProhibit && requestSans.length > 0) {
errors.push(`${sanType} SAN is prohibited by template policy`);
// eslint-disable-next-line no-continue
continue;
}
if (hasMandatory && requestSans.length === 0) {
errors.push(`${sanType} SAN is mandatory but not provided in request`);
// eslint-disable-next-line no-continue
continue;
}
if (requestSans.length > 0) {
const policiesWithValues = policies.filter(
(p) =>
p.value &&
p.value.length > 0 &&
(p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL)
);
if (policiesWithValues.length > 0) {
const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []);
requestSans.forEach((san) => {
const validation = validateValueAgainstConstraints(san.value, allAllowedValues, `${sanType} SAN`);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
}
});
}
}
}
const requestSanTypes = new Set(request.subjectAlternativeNames?.map((san) => san.type) || []);
for (const requestSanType of requestSanTypes) {
if (!templateSanTypes.has(requestSanType)) {
errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`);
}
}
// Validate algorithms with new structure
if (request.signatureAlgorithm) {
if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) {
const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map(
mapTemplateSignatureAlgorithmToApi
);
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.signatureAlgorithm) {
} else if (!template.algorithms?.signature) {
errors.push(
`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)`
);
@@ -393,40 +520,19 @@ export const certificateTemplateV2ServiceFactory = ({
}
if (request.keyAlgorithm) {
if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) {
const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi);
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.keyAlgorithm) {
} else if (!template.algorithms?.keyAlgorithm) {
errors.push(
`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)`
);
}
}
if (request.validity?.ttl && template.validity) {
const requestDuration = parseTTL(request.validity.ttl);
const maxDuration = convertToMilliseconds(
template.validity.maxDuration.value,
template.validity.maxDuration.unit
);
if (requestDuration > maxDuration) {
errors.push(`Requested validity period exceeds maximum allowed duration`);
}
if (template.validity.minDuration) {
const minDuration = convertToMilliseconds(
template.validity.minDuration.value,
template.validity.minDuration.unit
);
if (requestDuration < minDuration) {
errors.push(`Requested validity period is below minimum required duration`);
}
}
}
// 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."
@@ -437,30 +543,32 @@ export const certificateTemplateV2ServiceFactory = ({
errors.push("notBefore must be earlier than notAfter");
}
if ((request.notBefore || request.notAfter) && template.validity) {
// 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);
const maxDuration = convertToMilliseconds(
template.validity.maxDuration.value,
template.validity.maxDuration.unit
);
if (requestDuration > maxDuration) {
errors.push(`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration`);
}
if (template.validity.minDuration) {
const minDuration = convertToMilliseconds(
template.validity.minDuration.value,
template.validity.minDuration.unit
if (requestDuration > maxDurationMs) {
errors.push(
`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}`
);
if (requestDuration < minDuration) {
errors.push(`Requested validity period (notBefore to notAfter) is below minimum required duration`);
}
}
}
}
@@ -505,16 +613,33 @@ export const certificateTemplateV2ServiceFactory = ({
throw new Error("Template data is required");
}
if (data.attributes) {
validateSubjectAttributePolicy(data.attributes);
if (data.subject) {
validateSubjectAttributePolicy(data.subject);
}
const slug = data.slug || generateTemplateSlug();
if (data.sans) {
validateSanPolicy(data.sans);
}
if (data.keyUsages) {
validateKeyUsagePolicy(data.keyUsages);
}
if (data.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(data.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({
...data,
slug: uniqueSlug,
name: uniqueSlug,
projectId
});
@@ -555,14 +680,29 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates
);
if (data.attributes) {
validateSubjectAttributePolicy(data.attributes);
if (data.subject) {
validateSubjectAttributePolicy(data.subject);
}
if (data.sans) {
validateSanPolicy(data.sans);
}
if (data.keyUsages) {
validateKeyUsagePolicy(data.keyUsages);
}
if (data.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(data.extendedKeyUsages);
}
const updateData = { ...data };
if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId);
updateData.slug = uniqueSlug;
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);
@@ -636,7 +776,7 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates
);
const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId);
const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
@@ -734,8 +874,8 @@ export const certificateTemplateV2ServiceFactory = ({
throw new ForbiddenRequestError({
message:
profilesUsingTemplate.length > 0
? `Cannot delete template '${template.slug}' 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.slug}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.`
? `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.`
});
}
@@ -1,101 +1,71 @@
import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
export interface TTemplateV2Policy {
attributes: Array<{
subject?: Array<{
type: CertSubjectAttributeType;
include: CertIncludeType;
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages: {
requiredUsages?: { all: CertKeyUsageType[] };
optionalUsages?: { all: CertKeyUsageType[] };
};
extendedKeyUsages: {
requiredUsages?: { all: CertExtendedKeyUsageType[] };
optionalUsages?: { all: CertExtendedKeyUsageType[] };
};
subjectAlternativeNames: Array<{
sans?: Array<{
type: CertSubjectAlternativeNameType;
include: CertIncludeType;
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>;
validity: {
maxDuration: { value: number; unit: CertDurationUnit };
minDuration?: { value: number; unit: CertDurationUnit };
keyUsages?: {
allowed?: CertKeyUsageType[];
required?: CertKeyUsageType[];
denied?: CertKeyUsageType[];
};
signatureAlgorithm: {
allowedAlgorithms: string[];
defaultAlgorithm: string;
extendedKeyUsages?: {
allowed?: CertExtendedKeyUsageType[];
required?: CertExtendedKeyUsageType[];
denied?: CertExtendedKeyUsageType[];
};
keyAlgorithm: {
allowedKeyTypes: string[];
defaultKeyType: string;
algorithms?: {
signature?: string[];
keyAlgorithm?: string[];
};
validity?: {
max?: string;
};
}
export type TCertificateTemplateV2 = Omit<
TCertificateTemplatesV2,
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
> & {
attributes: TTemplateV2Policy["attributes"];
keyUsages: TTemplateV2Policy["keyUsages"];
extendedKeyUsages: TTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames: TTemplateV2Policy["subjectAlternativeNames"];
validity: TTemplateV2Policy["validity"];
signatureAlgorithm: TTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm: TTemplateV2Policy["keyAlgorithm"];
};
export type TCertificateTemplateV2Insert = Omit<
TCertificateTemplatesV2Insert,
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
> & {
attributes?: TTemplateV2Policy["attributes"];
export type TCertificateTemplateV2 = TCertificateTemplatesV2 & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames?: TTemplateV2Policy["subjectAlternativeNames"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
};
export type TCertificateTemplateV2Insert = TCertificateTemplatesV2Insert & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
signatureAlgorithm?: TTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm?: TTemplateV2Policy["keyAlgorithm"];
};
export type TCertificateTemplateV2Update = Partial<
Pick<
TCertificateTemplateV2,
| "slug"
| "description"
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
"name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity"
>
>;
export interface TCertificateRequest {
commonName?: string;
organization?: string;
organizationName?: string;
organizationUnit?: string;
locality?: string;
@@ -102,12 +102,12 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation:
const validateAlgorithmCompatibility = (
ca: TCertificateAuthorityWithAssociatedCa,
template: {
signatureAlgorithm?: {
allowedAlgorithms?: string[];
algorithms?: {
signature?: string[];
};
}
) => {
if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) {
if (!template.algorithms?.signature || template.algorithms.signature.length === 0) {
return;
}
@@ -116,7 +116,7 @@ const validateAlgorithmCompatibility = (
throw new BadRequestError({ message: "CA key algorithm not found" });
}
const compatibleAlgorithms = template.signatureAlgorithm.allowedAlgorithms.filter((sigAlg: string) => {
const compatibleAlgorithms = template.algorithms.signature.filter((sigAlg: string) => {
const parts = sigAlg.split("-");
const keyType = parts[parts.length - 1];
@@ -133,7 +133,7 @@ const validateAlgorithmCompatibility = (
if (compatibleAlgorithms.length === 0) {
throw new BadRequestError({
message: `Template signature algorithms (${template.signatureAlgorithm.allowedAlgorithms.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
message: `Template signature algorithms (${template.algorithms.signature.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
});
}
};
@@ -180,7 +180,10 @@ export const certificateV3ServiceFactory = ({
});
}
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const mappedCertificateRequest = mapEnumsForValidation({
...certificateRequest,
subjectAlternativeNames: certificateRequest.altNames
});
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
@@ -217,26 +220,25 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm =
certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
message: "Signature algorithm is required by template policy but not provided in request"
});
}
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes);
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject);
const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
{ subjectAlternativeNames: certificateRequest.altNames },
template.subjectAlternativeNames
template.sans
);
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
@@ -326,18 +328,18 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
const effectiveSignatureAlgorithm = signatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
message: "Signature algorithm is required by template policy but not provided in request"
});
}