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.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project); t.foreign("projectId").references("id").inTable(TableName.Project);
t.string("slug").notNullable(); t.string("name").notNullable();
t.string("description"); t.string("description");
t.jsonb("attributes"); t.jsonb("subject");
t.jsonb("sans");
t.jsonb("keyUsages"); t.jsonb("keyUsages");
t.jsonb("extendedKeyUsages"); t.jsonb("extendedKeyUsages");
t.jsonb("subjectAlternativeNames"); t.jsonb("algorithms");
t.jsonb("validity"); t.jsonb("validity");
t.jsonb("signatureAlgorithm");
t.jsonb("keyAlgorithm");
t.timestamps(true, true, true); t.timestamps(true, true, true);
t.unique(["slug", "projectId"]); t.unique(["name", "projectId"]);
}); });
await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2); await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);
@@ -10,15 +10,14 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplatesV2Schema = z.object({ export const CertificateTemplatesV2Schema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
projectId: z.string(), projectId: z.string(),
slug: z.string(), name: z.string(),
description: z.string().nullable().optional(), 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(), keyUsages: z.unknown().nullable().optional(),
extendedKeyUsages: z.unknown().nullable().optional(), extendedKeyUsages: z.unknown().nullable().optional(),
subjectAlternativeNames: z.unknown().nullable().optional(), algorithms: z.unknown().nullable().optional(),
validity: z.unknown().nullable().optional(), validity: z.unknown().nullable().optional(),
signatureAlgorithm: z.unknown().nullable().optional(),
keyAlgorithm: z.unknown().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });
+1 -1
View File
@@ -11,8 +11,8 @@ import { registerAuthRoutes } from "./auth-router";
import { registerProjectBotRouter } from "./bot-router"; import { registerProjectBotRouter } from "./bot-router";
import { registerCaRouter } from "./certificate-authority-router"; import { registerCaRouter } from "./certificate-authority-router";
import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertificateProfilesRouter } from "./certificate-profiles-router";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router";
import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";
@@ -1,110 +1,133 @@
import { z } from "zod"; import { z } from "zod";
import { CertificateTemplatesV2Schema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs"; import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { import {
CertDurationUnit,
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType, CertSubjectAlternativeNameType,
CertSubjectAttributeType CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants"; } 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) => { 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({ server.route({
method: "POST", method: "POST",
url: "/", url: "/",
@@ -114,39 +137,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
body: z body: createCertificateTemplateV2Schema,
.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)"
}
),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: certificateTemplateV2ResponseSchema
}) })
} }
}, },
@@ -169,7 +163,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.CREATE_CERTIFICATE_TEMPLATE, type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug, name: certificateTemplate.name,
projectId: certificateTemplate.projectId projectId: certificateTemplate.projectId
} }
} }
@@ -196,7 +190,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplates: CertificateTemplatesV2Schema.array(), certificateTemplates: certificateTemplateV2ResponseSchema.array(),
totalCount: z.number() totalCount: z.number()
}) })
} }
@@ -240,7 +234,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: certificateTemplateV2ResponseSchema
}) })
} }
}, },
@@ -261,7 +255,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.GET_CERTIFICATE_TEMPLATE, type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug name: certificateTemplate.name
} }
} }
}); });
@@ -282,20 +276,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
params: z.object({ params: z.object({
id: z.string().uuid() id: z.string().uuid()
}), }),
body: z.object({ body: updateCertificateTemplateV2Schema,
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()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: certificateTemplateV2ResponseSchema
}) })
} }
}, },
@@ -317,7 +301,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.UPDATE_CERTIFICATE_TEMPLATE, type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug name: certificateTemplate.name
} }
} }
}); });
@@ -340,7 +324,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: certificateTemplateV2ResponseSchema
}) })
} }
}, },
@@ -361,7 +345,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.DELETE_CERTIFICATE_TEMPLATE, type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug name: certificateTemplate.name
} }
} }
}); });
@@ -32,6 +32,17 @@ export enum CertIncludeType {
PROHIBIT = "prohibit" PROHIBIT = "prohibit"
} }
export enum CertAttributeRule {
ALLOW = "allow",
DENY = "deny"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertDurationUnit { export enum CertDurationUnit {
DAYS = "days", DAYS = "days",
MONTHS = "months", MONTHS = "months",
@@ -39,7 +50,9 @@ export enum CertDurationUnit {
} }
export enum CertSubjectAttributeType { export enum CertSubjectAttributeType {
COMMON_NAME = "common_name" COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
} }
export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => { 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 INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); 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>, request: Record<string, unknown>,
templateAttributes?: Array<{ templateAttributes?: Array<{
type: string; type: string;
include: "mandatory" | "optional" | "prohibit"; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}> }>
): Record<string, string | undefined> => { ): Record<string, string | undefined> => {
const subject: Record<string, string> = {}; const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = { const attributeMap: Record<string, string> = {
common_name: "commonName" common_name: "commonName",
organization: "organization",
country: "country"
}; };
if (!templateAttributes || templateAttributes.length === 0) { if (!templateAttributes || templateAttributes.length === 0) {
throw new Error( return subject;
"Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints."
);
} }
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) => { templateAttributes.forEach((attr) => {
if (attr.include === "prohibit") {
return;
}
const requestKey = attributeMap[attr.type]; const requestKey = attributeMap[attr.type];
const value = request[requestKey]; const value = request[requestKey];
if (value && typeof value === "string") { if (value && typeof value === "string" && (attr.allowed || attr.required)) {
subject[attr.type] = value; subject[attr.type] = value;
} }
}); });
@@ -98,8 +82,9 @@ export const buildSubjectAlternativeNamesFromTemplate = (
request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, request: { subjectAlternativeNames?: Array<{ type: string; value: string }> },
templateSans?: Array<{ templateSans?: Array<{
type: string; type: string;
include: "mandatory" | "optional" | "prohibit"; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}> }>
): string => { ): string => {
if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) {
@@ -107,33 +92,13 @@ export const buildSubjectAlternativeNamesFromTemplate = (
} }
if (!templateSans || templateSans.length === 0) { if (!templateSans || templateSans.length === 0) {
if (request.subjectAlternativeNames.length > 0) { return request.subjectAlternativeNames.map((san) => san.value).join(",");
throw new Error(
"Template must define allowed subject alternative names. Cannot issue certificate with SANs when template has no SAN constraints."
);
}
return "";
} }
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[] = []; const allowedSans: string[] = [];
request.subjectAlternativeNames.forEach((san) => { request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type; allowedSans.push(san.value);
if (!prohibitedTypes.has(sanType)) {
allowedSans.push(san.value);
}
}); });
return allowedSans.join(","); return allowedSans.join(",");
@@ -68,7 +68,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
(tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"), (tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
(tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"), (tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
(tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"), (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("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
(tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), (tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
(tx || db) (tx || db)
@@ -23,19 +23,12 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
const serialized = { ...data } as Record<string, unknown>; const serialized = { ...data } as Record<string, unknown>;
const jsonFields = [
"attributes", const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
jsonFields.forEach((field) => { jsonFields.forEach((field) => {
const value = (data as Record<string, unknown>)[field]; const value = serialized[field];
if (value !== undefined) { if (value !== undefined && typeof value !== "string") {
serialized[field] = JSON.stringify(value); serialized[field] = JSON.stringify(value);
} }
}); });
@@ -44,21 +37,15 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
}; };
const parseJsonFields = (raw: Record<string, unknown>): TCertificateTemplateV2 => { const parseJsonFields = (raw: Record<string, unknown>): TCertificateTemplateV2 => {
const jsonFields = [ const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
"attributes", const parsed = { ...raw } as Record<string, unknown>;
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
const parsed = { ...raw };
jsonFields.forEach((field) => { jsonFields.forEach((field) => {
const value = raw[field]; const value = raw[field];
if (value) { if (value !== null && value !== undefined) {
parsed[field] = typeof value === "string" ? JSON.parse(value) : value; parsed[field] = typeof value === "string" ? JSON.parse(value) : value;
} else {
parsed[field] = undefined;
} }
}); });
@@ -143,7 +130,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) { if (search) {
query = query.where((builder) => { 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) { if (search) {
query = query.where((builder) => { 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 { try {
const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2) const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
.where({ slug, projectId }) .where({ name, projectId })
.first(); .first();
if (!certificateTemplateV2) { if (!certificateTemplateV2) {
@@ -192,7 +179,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
return parseJsonFields(certificateTemplateV2); return parseJsonFields(certificateTemplateV2);
} catch (error) { } 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, findById,
findByProjectId, findByProjectId,
countByProjectId, countByProjectId,
findBySlugAndProjectId, findByNameAndProjectId,
isTemplateInUse, isTemplateInUse,
getProfilesUsingTemplate getProfilesUsingTemplate
}; };
@@ -1,166 +1,123 @@
import { z } from "zod"; import { z } from "zod";
import { slugSchema } from "@app/server/lib/schemas";
import { import {
CertDurationUnit,
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType, CertSubjectAlternativeNameType,
CertSubjectAttributeType CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants"; } from "@app/services/certificate-common/certificate-constants";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const includeTypeSchema = z.nativeEnum(CertIncludeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const durationUnitSchema = z.nativeEnum(CertDurationUnit); const templateV2SubjectSchema = z
export const templateV2AttributeSchema = z
.object({ .object({
type: attributeTypeSchema, type: attributeTypeSchema,
include: includeTypeSchema, allowed: z.array(z.string()).optional(),
value: z.array(z.string()).optional() required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
}) })
.refine( .refine(
(data) => { (data) => {
if (data.type === "common_name" && data.value && data.value.length > 1) { if (!data.allowed && !data.required && !data.denied) {
return false;
}
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
return false; return false;
} }
return true; 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({ const templateV2KeyUsagesSchema = z
requiredUsages: z .object({
.object({ allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
all: z.array(z.nativeEnum(CertKeyUsageType)) required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
}) denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
.optional(), })
optionalUsages: z .refine(
.object({ (data) => {
all: z.array(z.nativeEnum(CertKeyUsageType)) if (!data.allowed && !data.required && !data.denied) {
}) return false;
.optional() }
}); return true;
},
{
message: "Key usages must have at least one allowed, required, or denied value"
}
);
export const templateV2ExtendedKeyUsagesSchema = z.object({ const templateV2ExtendedKeyUsagesSchema = z
requiredUsages: z .object({
.object({ allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
}) denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
.optional(), })
optionalUsages: z .refine(
.object({ (data) => {
all: z.array(z.nativeEnum(CertExtendedKeyUsageType)) if (!data.allowed && !data.required && !data.denied) {
}) return false;
.optional() }
}); return true;
},
{
message: "Extended key usages must have at least one allowed, required, or denied value"
}
);
export const templateV2SanSchema = z const templateV2SanSchema = z
.object({ .object({
type: sanTypeSchema, type: sanTypeSchema,
include: includeTypeSchema, allowed: z.array(z.string()).optional(),
value: z.array(z.string()).optional() required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
}) })
.refine( .refine(
(data) => { (data) => {
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) { if (!data.allowed && !data.required && !data.denied) {
return false; return false;
} }
return true; 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({ const templateV2ValiditySchema = z.object({
maxDuration: z.object({ max: z
value: z.number().positive(), .string()
unit: durationUnitSchema .regex(/^\d+[dhmy]$/, {
}), message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
minDuration: z
.object({
value: z.number().positive(),
unit: durationUnitSchema
}) })
.optional() .optional()
}); });
export const templateV2SignatureAlgorithmSchema = z const templateV2AlgorithmsSchema = z.object({
.object({ signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(),
allowedAlgorithms: z.array(z.string()).min(1), keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional()
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()
}); });
export const updateCertificateTemplateV2Schema = z.object({ export const certificateTemplateV2ResponseSchema = z.object({
slug: slugSchema({ min: 1, max: 255 }).optional(), id: z.string().uuid(),
description: z.string().max(1000).optional(), projectId: z.string(),
attributes: z.array(templateV2AttributeSchema).optional(), name: z.string(),
description: z.string().nullable().optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(), algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional(), validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(), createdAt: z.date(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional() updatedAt: z.date()
});
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()
}); });
export const certificateRequestSchema = z.object({ export const certificateRequestSchema = z.object({
commonName: z.string().optional(), commonName: z.string().optional(),
organization: z.string().optional(),
organizationName: z.string().optional(),
country: z.string().optional(),
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
subjectAlternativeNames: z 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 { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; 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 { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
import { import {
TCertificateRequest, TCertificateRequest,
@@ -57,75 +57,86 @@ export const certificateTemplateV2ServiceFactory = ({
} }
}; };
const convertToMilliseconds = (value: number, unit: "days" | "months" | "years"): number => { const validateSubjectAttributePolicy = (
switch (unit) { subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
case "days": ) => {
return value * 24 * 60 * 60 * 1000; if (!subject || subject.length === 0) return;
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 = (attributes: Array<{ type: string; include: string; value?: string[] }>) => { // Validate each subject attribute policy
if (!attributes || attributes.length === 0) return; for (const attr of subject) {
// Ensure at least one field is provided
const attributesByType = attributes.reduce( if (!attr.allowed && !attr.required && !attr.denied) {
(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) {
throw new ForbiddenRequestError({ 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) { // Check for duplicate values within arrays
throw new ForbiddenRequestError({ const arrays = [
message: `When a mandatory value exists for subject attribute type '${type}', no other values (optional or forbidden) are allowed for that attribute type.` { 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 = ( const validateSanPolicy = (
request: TCertificateRequest, sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
attrType: CertSubjectAttributeType | string ) => {
): string | undefined => { if (!sans || sans.length === 0) return;
switch (attrType) {
case CertSubjectAttributeType.COMMON_NAME: // Validate each SAN policy
case "common_name": for (const san of sans) {
return request.commonName; if (!san.allowed && !san.required && !san.denied) {
default: throw new ForbiddenRequestError({
return undefined; 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 => { const generateTemplateSlug = (baseName?: string): string => {
if (baseSlug) { if (baseName) {
return slugify(baseSlug); return slugify(baseName);
} }
return slugify(alphaNumericNanoId(12)); return slugify(alphaNumericNanoId(12));
}; };
const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise<string> => { 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)) { if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
return desiredSlug; return desiredSlug;
} }
const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId); const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId);
if (!existingAlternative) { if (!existingAlternative) {
return alternativeSlug; return alternativeSlug;
} }
@@ -139,8 +150,12 @@ export const certificateTemplateV2ServiceFactory = ({
}; };
const createWildcardRegex = (pattern: string): RegExp => { const createWildcardRegex = (pattern: string): RegExp => {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); const wildcardRegex = new RE2(/\*/g);
const regexPattern = escaped.replace(/\*/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}$`); return new RE2(`^${regexPattern}$`);
}; };
@@ -166,6 +181,64 @@ export const certificateTemplateV2ServiceFactory = ({
return mapping[templateFormat] || templateFormat; 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 = ( const validateValueAgainstConstraints = (
value: string, value: string,
allowedValues: string[], allowedValues: string[],
@@ -184,7 +257,7 @@ export const certificateTemplateV2ServiceFactory = ({
if (regex.test(value)) { if (regex.test(value)) {
return { isValid: true }; return { isValid: true };
} }
} catch { } catch (error) {
if (allowedValue === value) { if (allowedValue === value) {
return { isValid: true }; return { isValid: true };
} }
@@ -213,89 +286,186 @@ export const certificateTemplateV2ServiceFactory = ({
const errors: string[] = []; const errors: string[] = [];
const warnings: 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>(); if (subjectPolicies && subjectPolicies.length > 0) {
template.attributes?.forEach((attrPolicy) => { // Validate each template subject attribute policy
const existing = attributePoliciesByType.get(attrPolicy.type) || []; for (const attrPolicy of subjectPolicies) {
attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]); const requestValue = requestAttributes.get(attrPolicy.type);
});
for (const [attrType, policies] of attributePoliciesByType) { // Check denied values first
const requestValue = getRequestAttributeValue(request, attrType); if (requestValue && attrPolicy.denied && attrPolicy.denied.length > 0) {
const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type);
const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY); if (validation.isValid) {
const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT); errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`);
// Skip further validation for this attribute if it's denied
if (hasProhibit && requestValue) { } else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
errors.push(`${attrType} is prohibited by template policy`); // Check allowed values if present and not denied
// eslint-disable-next-line no-continue const allowedValidation = validateValueAgainstConstraints(
continue; requestValue,
} attrPolicy.allowed,
attrPolicy.type
if (hasMandatory && !requestValue) { );
errors.push(`${attrType} is mandatory but not provided in request`); if (!allowedValidation.isValid && allowedValidation.error) {
// eslint-disable-next-line no-continue errors.push(allowedValidation.error);
continue; }
} }
} else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
if (requestValue) { // Check allowed values if present and not denied
const policiesWithValues = policies.filter( const allowedValidation = validateValueAgainstConstraints(requestValue, attrPolicy.allowed, attrPolicy.type);
(p) => if (!allowedValidation.isValid && allowedValidation.error) {
p.value && errors.push(allowedValidation.error);
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);
} }
} }
} }
}
const requestAttributeTypes: CertSubjectAttributeType[] = []; // Check for required subject attributes
if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME); 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) { // Check if any request attributes are not covered by template policies
if (!templateAttributeTypes.has(requestAttrType)) { for (const [attrType] of requestAttributes) {
errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`); 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) { // Validate Subject Alternative Names
const missingRequired = template.keyUsages.requiredUsages.all.filter( const sansPolicies = template.sans;
(usage) => !request.keyUsages?.includes(usage) 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) { if (missingRequired.length > 0) {
errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); errors.push(`Missing required key usages: ${missingRequired.join(", ")}`);
} }
} }
if (request.keyUsages && (template.keyUsages.requiredUsages || template.keyUsages.optionalUsages)) { // Check DENIED key usages - must not have any denied usages
const allAllowedUsages = [ if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) {
...(template.keyUsages.requiredUsages?.all || []), const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage));
...(template.keyUsages.optionalUsages?.all || []) if (deniedUsages.length > 0) {
]; errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`);
}
}
if (allAllowedUsages.length > 0) { // Check ALLOWED key usages - if present, all usages must be in allowed list
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) {
if (invalidUsages.length > 0) { const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])];
errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); 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) { } else if (request.keyUsages && request.keyUsages.length > 0) {
errors.push(`Key usages are not allowed by template policy (not defined in template)`); errors.push(`Key usages are not allowed by template policy (not defined in template)`);
} }
if (template.extendedKeyUsages) { // Validate extended key usages
if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) { const extendedKeyUsagePolicy = template.extendedKeyUsages;
const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter( 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) (usage) => !request.extendedKeyUsages?.includes(usage)
); );
if (missingRequired.length > 0) { 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 ( if (
request.extendedKeyUsages && request.extendedKeyUsages &&
(template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages) extendedKeyUsagePolicy &&
extendedKeyUsagePolicy.allowed &&
extendedKeyUsagePolicy.allowed.length > 0
) { ) {
const allAllowedUsages = [ const allAllowedExtendedUsages = [
...(template.extendedKeyUsages.requiredUsages?.all || []), ...(extendedKeyUsagePolicy.required || []),
...(template.extendedKeyUsages.optionalUsages?.all || []) ...(extendedKeyUsagePolicy.allowed || [])
]; ];
const invalidExtendedUsages = request.extendedKeyUsages.filter(
if (allAllowedUsages.length > 0) { (usage) => !allAllowedExtendedUsages.includes(usage)
const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage)); );
if (invalidUsages.length > 0) { if (invalidExtendedUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`); errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`);
}
} }
} }
} else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) {
errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); 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) || []); // Validate algorithms with new structure
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)`);
}
}
if (request.signatureAlgorithm) { if (request.signatureAlgorithm) {
if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) { if (template.algorithms?.signature && template.algorithms.signature.length > 0) {
const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map( const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi);
mapTemplateSignatureAlgorithmToApi
);
if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) {
errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
} }
} else if (!template.signatureAlgorithm) { } else if (!template.algorithms?.signature) {
errors.push( errors.push(
`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` `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 (request.keyAlgorithm) {
if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) { if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) {
const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi); const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi);
if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) {
errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`);
} }
} else if (!template.keyAlgorithm) { } else if (!template.algorithms?.keyAlgorithm) {
errors.push( errors.push(
`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)`
); );
} }
} }
if (request.validity?.ttl && template.validity) { // Validate validity with new structure
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`);
}
}
}
if (request.validity?.ttl && (request.notBefore || request.notAfter)) { if (request.validity?.ttl && (request.notBefore || request.notAfter)) {
errors.push( errors.push(
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." "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"); 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 notBefore = request.notBefore || new Date();
const { notAfter } = request; const { notAfter } = request;
if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) {
const requestDuration = notAfter.getTime() - notBefore.getTime(); const requestDuration = notAfter.getTime() - notBefore.getTime();
const maxDurationMs = parseTTL(template.validity.max);
const maxDuration = convertToMilliseconds( if (requestDuration > maxDurationMs) {
template.validity.maxDuration.value, errors.push(
template.validity.maxDuration.unit `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}`
);
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 < 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"); throw new Error("Template data is required");
} }
if (data.attributes) { if (data.subject) {
validateSubjectAttributePolicy(data.attributes); 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 uniqueSlug = await ensureUniqueSlug(projectId, slug);
const template = await certificateTemplateV2DAL.create({ const template = await certificateTemplateV2DAL.create({
...data, ...data,
slug: uniqueSlug, name: uniqueSlug,
projectId projectId
}); });
@@ -555,14 +680,29 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates ProjectPermissionSub.CertificateTemplates
); );
if (data.attributes) { if (data.subject) {
validateSubjectAttributePolicy(data.attributes); validateSubjectAttributePolicy(data.subject);
}
if (data.sans) {
validateSanPolicy(data.sans);
}
if (data.keyUsages) {
validateKeyUsagePolicy(data.keyUsages);
}
if (data.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(data.extendedKeyUsages);
} }
const updateData = { ...data }; const updateData = { ...data };
if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) { if (data.name && typeof data.name === "string") {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId); const newSlug = generateTemplateSlug(data.name);
updateData.slug = uniqueSlug; if (newSlug !== existingTemplate.name) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId);
updateData.name = uniqueSlug;
}
} }
const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData);
@@ -636,7 +776,7 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates ProjectPermissionSub.CertificateTemplates
); );
const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId); const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId);
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found" }); throw new NotFoundError({ message: "Certificate template not found" });
} }
@@ -734,8 +874,8 @@ export const certificateTemplateV2ServiceFactory = ({
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: message:
profilesUsingTemplate.length > 0 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.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.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 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 { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2";
import { import {
CertDurationUnit,
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType, CertSubjectAlternativeNameType,
CertSubjectAttributeType CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants"; } from "@app/services/certificate-common/certificate-constants";
export interface TTemplateV2Policy { export interface TTemplateV2Policy {
attributes: Array<{ subject?: Array<{
type: CertSubjectAttributeType; type: CertSubjectAttributeType;
include: CertIncludeType; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}>; }>;
keyUsages: { sans?: Array<{
requiredUsages?: { all: CertKeyUsageType[] };
optionalUsages?: { all: CertKeyUsageType[] };
};
extendedKeyUsages: {
requiredUsages?: { all: CertExtendedKeyUsageType[] };
optionalUsages?: { all: CertExtendedKeyUsageType[] };
};
subjectAlternativeNames: Array<{
type: CertSubjectAlternativeNameType; type: CertSubjectAlternativeNameType;
include: CertIncludeType; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}>; }>;
validity: { keyUsages?: {
maxDuration: { value: number; unit: CertDurationUnit }; allowed?: CertKeyUsageType[];
minDuration?: { value: number; unit: CertDurationUnit }; required?: CertKeyUsageType[];
denied?: CertKeyUsageType[];
}; };
signatureAlgorithm: { extendedKeyUsages?: {
allowedAlgorithms: string[]; allowed?: CertExtendedKeyUsageType[];
defaultAlgorithm: string; required?: CertExtendedKeyUsageType[];
denied?: CertExtendedKeyUsageType[];
}; };
keyAlgorithm: { algorithms?: {
allowedKeyTypes: string[]; signature?: string[];
defaultKeyType: string; keyAlgorithm?: string[];
};
validity?: {
max?: string;
}; };
} }
export type TCertificateTemplateV2 = Omit< export type TCertificateTemplateV2 = TCertificateTemplatesV2 & {
TCertificateTemplatesV2, subject?: TTemplateV2Policy["subject"];
| "attributes" sans?: TTemplateV2Policy["sans"];
| "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"];
keyUsages?: TTemplateV2Policy["keyUsages"]; keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; 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"]; validity?: TTemplateV2Policy["validity"];
signatureAlgorithm?: TTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm?: TTemplateV2Policy["keyAlgorithm"];
}; };
export type TCertificateTemplateV2Update = Partial< export type TCertificateTemplateV2Update = Partial<
Pick< Pick<
TCertificateTemplateV2, TCertificateTemplateV2,
| "slug" "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity"
| "description"
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
> >
>; >;
export interface TCertificateRequest { export interface TCertificateRequest {
commonName?: string; commonName?: string;
organization?: string;
organizationName?: string; organizationName?: string;
organizationUnit?: string; organizationUnit?: string;
locality?: string; locality?: string;
@@ -102,12 +102,12 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation:
const validateAlgorithmCompatibility = ( const validateAlgorithmCompatibility = (
ca: TCertificateAuthorityWithAssociatedCa, ca: TCertificateAuthorityWithAssociatedCa,
template: { template: {
signatureAlgorithm?: { algorithms?: {
allowedAlgorithms?: string[]; signature?: string[];
}; };
} }
) => { ) => {
if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) { if (!template.algorithms?.signature || template.algorithms.signature.length === 0) {
return; return;
} }
@@ -116,7 +116,7 @@ const validateAlgorithmCompatibility = (
throw new BadRequestError({ message: "CA key algorithm not found" }); 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 parts = sigAlg.split("-");
const keyType = parts[parts.length - 1]; const keyType = parts[parts.length - 1];
@@ -133,7 +133,7 @@ const validateAlgorithmCompatibility = (
if (compatibleAlgorithms.length === 0) { if (compatibleAlgorithms.length === 0) {
throw new BadRequestError({ 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( const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId, profile.certificateTemplateId,
mappedCertificateRequest mappedCertificateRequest
@@ -217,26 +220,25 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm;
certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({ 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({ 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( const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
{ subjectAlternativeNames: certificateRequest.altNames }, { subjectAlternativeNames: certificateRequest.altNames },
template.subjectAlternativeNames template.sans
); );
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
@@ -326,18 +328,18 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; const effectiveSignatureAlgorithm = signatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType; const effectiveKeyAlgorithm = keyAlgorithm;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) { if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({ 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({ 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"
}); });
} }
+1 -1
View File
@@ -1,5 +1,4 @@
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
export { export {
useCreateCa, useCreateCa,
useCreateCertificate, useCreateCertificate,
@@ -24,3 +23,4 @@ export {
useListCasByTypeAndProjectId, useListCasByTypeAndProjectId,
useListExternalCasByProjectId useListExternalCasByProjectId
} from "./queries"; } from "./queries";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
@@ -123,78 +123,74 @@ export type TListCertificateTemplatesDTO = {
}; };
export type TCertificateTemplateV2Policy = { export type TCertificateTemplateV2Policy = {
attributes: Array<{ subject?: Array<{
type: "common_name"; type: "common_name" | "organization" | "country";
include: "mandatory" | "optional" | "prohibit"; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}>; }>;
keyUsages: { sans?: Array<{
requiredUsages: { all: string[] };
optionalUsages: { all: string[] };
};
extendedKeyUsages: {
requiredUsages: { all: string[] };
optionalUsages: { all: string[] };
};
subjectAlternativeNames: Array<{
type: "dns_name" | "ip_address" | "email" | "uri"; type: "dns_name" | "ip_address" | "email" | "uri";
include: "mandatory" | "optional" | "prohibit"; allowed?: string[];
value?: string[]; required?: string[];
denied?: string[];
}>; }>;
validity: { keyUsages?: {
maxDuration: { value: number; unit: "days" | "months" | "years" }; allowed?: string[];
minDuration?: { value: number; unit: "days" | "months" | "years" }; required?: string[];
denied?: string[];
}; };
signatureAlgorithm: { extendedKeyUsages?: {
allowedAlgorithms: string[]; allowed?: string[];
defaultAlgorithm: string; required?: string[];
denied?: string[];
}; };
keyAlgorithm: { algorithms?: {
allowedKeyTypes: string[]; signature?: string[];
defaultKeyType: string; keyAlgorithm?: string[];
};
validity?: {
max?: string;
}; };
}; };
export type TCertificateTemplateV2New = { export type TCertificateTemplateV2New = {
id: string; id: string;
projectId: string; projectId: string;
slug: string; name: string;
description?: string; description?: string;
attributes: any; subject?: TCertificateTemplateV2Policy["subject"];
keyUsages: any; sans?: TCertificateTemplateV2Policy["sans"];
extendedKeyUsages: any; keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
subjectAlternativeNames: any; extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
validity: any; algorithms?: TCertificateTemplateV2Policy["algorithms"];
signatureAlgorithm: any; validity?: TCertificateTemplateV2Policy["validity"];
keyAlgorithm: any;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
export type TCreateCertificateTemplateV2NewDTO = { export type TCreateCertificateTemplateV2NewDTO = {
projectId: string; projectId: string;
slug: string; name: string;
description?: string; description?: string;
attributes: TCertificateTemplateV2Policy["attributes"]; subject?: TCertificateTemplateV2Policy["subject"];
keyUsages: TCertificateTemplateV2Policy["keyUsages"]; sans?: TCertificateTemplateV2Policy["sans"];
extendedKeyUsages: TCertificateTemplateV2Policy["extendedKeyUsages"]; keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
subjectAlternativeNames: TCertificateTemplateV2Policy["subjectAlternativeNames"]; extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
validity: TCertificateTemplateV2Policy["validity"]; algorithms?: TCertificateTemplateV2Policy["algorithms"];
signatureAlgorithm: TCertificateTemplateV2Policy["signatureAlgorithm"]; validity?: TCertificateTemplateV2Policy["validity"];
keyAlgorithm: TCertificateTemplateV2Policy["keyAlgorithm"];
}; };
export type TUpdateCertificateTemplateV2NewDTO = { export type TUpdateCertificateTemplateV2NewDTO = {
templateId: string; templateId: string;
slug?: string; name?: string;
description?: string; description?: string;
attributes?: TCertificateTemplateV2Policy["attributes"]; subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames?: TCertificateTemplateV2Policy["subjectAlternativeNames"]; algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"]; validity?: TCertificateTemplateV2Policy["validity"];
signatureAlgorithm?: TCertificateTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm?: TCertificateTemplateV2Policy["keyAlgorithm"];
}; };
export type TDeleteCertificateTemplateV2NewDTO = { export type TDeleteCertificateTemplateV2NewDTO = {
@@ -53,10 +53,10 @@ export const PkiManagerLayout = () => {
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: -150 }} exit={{ x: -150 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 dark w-full border-r md:w-60" className="dark w-full border-r border-mineshaft-600 bg-linear-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60"
> >
<nav className="items-between dark:scheme-dark flex h-full flex-col overflow-y-auto"> <nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<div className="border-mineshaft-600 flex items-center gap-3 border-b px-4 py-3.5 text-lg text-white"> <div className="flex items-center gap-3 border-b border-mineshaft-600 px-4 py-3.5 text-lg text-white">
<Lottie className="inline-block h-5 w-5 shrink-0" icon="note" /> <Lottie className="inline-block h-5 w-5 shrink-0" icon="note" />
PKI Manager PKI Manager
</div> </div>
@@ -267,7 +267,7 @@ export const PkiManagerLayout = () => {
<Menu> <Menu>
<Link to="/organization/projects"> <Link to="/organization/projects">
<MenuItem <MenuItem
className="text-mineshaft-400 hover:text-mineshaft-300 relative flex items-center gap-2 overflow-hidden text-sm" className="relative flex items-center gap-2 overflow-hidden text-sm text-mineshaft-400 hover:text-mineshaft-300"
leftIcon={ leftIcon={
<div className="w-6"> <div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} /> <FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
@@ -281,13 +281,13 @@ export const PkiManagerLayout = () => {
</div> </div>
</nav> </nav>
</motion.div> </motion.div>
<div className="bg-bunker-800 flex-1 overflow-y-auto overflow-x-hidden p-4 pt-8"> <div className="flex-1 overflow-x-hidden overflow-y-auto bg-bunker-800 p-4 pt-8">
{assumedPrivilegeDetails && <AssumePrivilegeModeBanner />} {assumedPrivilegeDetails && <AssumePrivilegeModeBanner />}
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</div> </div>
<div className="z-200 bg-bunker-800 flex h-screen w-screen flex-col items-center justify-center md:hidden"> <div className="z-200 flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" /> <FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200"> <p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `} {` ${t("common.no-mobile")} `}
@@ -1,9 +1,6 @@
/* eslint-disable react/no-array-index-key */ import { useCallback, useEffect, useMemo, useState } from "react";
/* eslint-disable no-nested-ternary */
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -35,13 +32,13 @@ import {
KEY_USAGES_OPTIONS, KEY_USAGES_OPTIONS,
SIGNATURE_ALGORITHMS_OPTIONS SIGNATURE_ALGORITHMS_OPTIONS
} from "@app/hooks/api/certificates/constants"; } from "@app/hooks/api/certificates/constants";
import { import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage
} from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import {
mapTemplateKeyAlgorithmToApi,
mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { CertificateContent } from "./CertificateContent"; import { CertificateContent } from "./CertificateContent";
@@ -109,9 +106,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null); const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const [allowedKeyUsages, setAllowedKeyUsages] = useState<string[]>([]); const [allowedKeyUsages, setAllowedKeyUsages] = useState<string[]>([]);
const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState<string[]>([]); const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState<string[]>([]);
const [requiredKeyUsages, setRequiredKeyUsages] = useState<string[]>([]);
const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState<string[]>([]);
const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]); const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]);
const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]); const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]);
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data: cert } = useGetCert( const { data: cert } = useGetCert(
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || "" (popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || ""
); );
@@ -133,7 +133,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
profileId: profileId ? profileId : "", profileId: profileId || "",
subjectAttributes: [{ type: "common_name", value: "" }], subjectAttributes: [{ type: "common_name", value: "" }],
subjectAltNames: [], subjectAltNames: [],
ttl: "30d", ttl: "30d",
@@ -144,276 +144,122 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
} }
}); });
const resetAllState = useCallback(() => {
setCertificateDetails(null);
setAllowedKeyUsages([]);
setAllowedExtendedKeyUsages([]);
setRequiredKeyUsages([]);
setRequiredExtendedKeyUsages([]);
setAllowedSignatureAlgorithms([]);
setAllowedKeyAlgorithms([]);
reset();
}, [reset]);
const selectedProfileId = watch("profileId"); const selectedProfileId = watch("profileId");
const selectedProfile = profilesData?.certificateProfiles?.find( const selectedProfile = useMemo(
(p) => p.id === selectedProfileId () => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId),
[profilesData?.certificateProfiles, selectedProfileId]
); );
const { data: templateData } = useGetCertificateTemplateV2ById({ const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: selectedProfile?.certificateTemplateId || "" templateId: selectedProfile?.certificateTemplateId || ""
}); });
const filteredKeyUsages = useMemo(() => {
if (allowedKeyUsages.length === 0) return KEY_USAGES_OPTIONS;
return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value));
}, [allowedKeyUsages]);
const filteredExtendedKeyUsages = useMemo(() => {
if (allowedExtendedKeyUsages.length === 0) return EXTENDED_KEY_USAGES_OPTIONS;
return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) =>
allowedExtendedKeyUsages.includes(value)
);
}, [allowedExtendedKeyUsages]);
const availableSignatureAlgorithms = useMemo(() => {
if (allowedSignatureAlgorithms.length === 0) {
return SIGNATURE_ALGORITHMS_OPTIONS;
}
return allowedSignatureAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [allowedSignatureAlgorithms]);
const availableKeyAlgorithms = useMemo(() => {
if (allowedKeyAlgorithms.length === 0) {
return certKeyAlgorithms;
}
return allowedKeyAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm);
return {
value: apiAlgorithm,
label: apiAlgorithm
};
});
}, [allowedKeyAlgorithms]);
useEffect(() => { useEffect(() => {
if (templateData && selectedProfile) { if (templateData && selectedProfile && popUp?.certificateIssuance?.isOpen) {
if (templateData.signatureAlgorithm?.allowedAlgorithms && templateData.signatureAlgorithm.allowedAlgorithms.length > 0) { if (templateData.algorithms?.signature && templateData.algorithms.signature.length > 0) {
const sigAlgMap: Record<string, string> = { setAllowedSignatureAlgorithms(templateData.algorithms.signature);
"SHA256-RSA": "RSA-SHA256", } else {
"SHA384-RSA": "RSA-SHA384", setAllowedSignatureAlgorithms([]);
"SHA512-RSA": "RSA-SHA512",
"SHA256-ECDSA": "ECDSA-SHA256",
"SHA384-ECDSA": "ECDSA-SHA384",
"SHA512-ECDSA": "ECDSA-SHA512"
};
let defaultValue = templateData.signatureAlgorithm.defaultAlgorithm;
if (defaultValue && sigAlgMap[defaultValue]) {
defaultValue = sigAlgMap[defaultValue];
}
const allowedValues = templateData.signatureAlgorithm.allowedAlgorithms.map((alg: string) => sigAlgMap[alg] || alg);
if (defaultValue && allowedValues.includes(defaultValue)) {
setValue("signatureAlgorithm", defaultValue);
} else if (allowedValues.length > 0) {
setValue("signatureAlgorithm", allowedValues[0]);
}
}
if (templateData.keyAlgorithm?.allowedKeyTypes && templateData.keyAlgorithm.allowedKeyTypes.length > 0) {
const keyAlgMap: Record<string, string> = {
"RSA-2048": CertKeyAlgorithm.RSA_2048,
"RSA-3072": CertKeyAlgorithm.RSA_3072,
"RSA-4096": CertKeyAlgorithm.RSA_4096,
"ECDSA-P256": CertKeyAlgorithm.ECDSA_P256,
"ECDSA-P384": CertKeyAlgorithm.ECDSA_P384,
[CertKeyAlgorithm.ECDSA_P256]: CertKeyAlgorithm.ECDSA_P256,
[CertKeyAlgorithm.ECDSA_P384]: CertKeyAlgorithm.ECDSA_P384
};
let defaultValue = templateData.keyAlgorithm.defaultKeyType;
if (defaultValue && keyAlgMap[defaultValue]) {
defaultValue = keyAlgMap[defaultValue];
}
const allowedValues = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => keyAlgMap[alg] || alg);
if (defaultValue && allowedValues.includes(defaultValue)) {
setValue("keyAlgorithm", defaultValue);
} else if (allowedValues.length > 0) {
setValue("keyAlgorithm", allowedValues[0]);
}
}
if (templateData.validity?.maxDuration) {
const { value, unit } = templateData.validity.maxDuration;
let ttlValue = "";
switch (unit) {
case "days":
ttlValue = `${value}d`;
break;
case "months":
ttlValue = `${value}m`;
break;
case "years":
ttlValue = `${value}y`;
break;
default:
ttlValue = `${value}d`;
}
setValue("ttl", ttlValue);
}
if (templateData.signatureAlgorithm?.allowedAlgorithms) {
const mappedSigAlgs = templateData.signatureAlgorithm.allowedAlgorithms.map(
(alg: string) => {
const sigAlgMap: 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 sigAlgMap[alg] || alg;
}
);
setAllowedSignatureAlgorithms(mappedSigAlgs);
}
if (templateData.keyAlgorithm?.allowedKeyTypes) {
const mappedKeyAlgs = templateData.keyAlgorithm.allowedKeyTypes.map((alg: string) => {
const keyAlgMap: Record<string, string> = {
"RSA-2048": CertKeyAlgorithm.RSA_2048,
"RSA-3072": CertKeyAlgorithm.RSA_3072,
"RSA-4096": CertKeyAlgorithm.RSA_4096,
"ECDSA-P256": CertKeyAlgorithm.ECDSA_P256,
"ECDSA-P384": CertKeyAlgorithm.ECDSA_P384
};
return keyAlgMap[alg] || alg;
});
setAllowedKeyAlgorithms(mappedKeyAlgs);
}
const allAllowedKeyUsages: string[] = [];
if (templateData.keyUsages?.requiredUsages?.all) {
allAllowedKeyUsages.push(...templateData.keyUsages.requiredUsages.all);
}
if (templateData.keyUsages?.optionalUsages?.all) {
allAllowedKeyUsages.push(...templateData.keyUsages.optionalUsages.all);
}
setAllowedKeyUsages([...new Set(allAllowedKeyUsages)]);
const allAllowedExtendedKeyUsages: string[] = [];
if (templateData.extendedKeyUsages?.requiredUsages?.all) {
allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.requiredUsages.all);
}
if (templateData.extendedKeyUsages?.optionalUsages?.all) {
allAllowedExtendedKeyUsages.push(...templateData.extendedKeyUsages.optionalUsages.all);
}
setAllowedExtendedKeyUsages([...new Set(allAllowedExtendedKeyUsages)]);
if (templateData.attributes && Array.isArray(templateData.attributes)) {
const subjectAttrs: Array<{
type: "common_name";
value: string;
}> = [];
templateData.attributes.forEach((attr) => {
if (
(attr.include === "mandatory" ||
attr.include === "optional" ||
attr.include === "prohibit") &&
attr.value &&
attr.value.length > 0
) {
attr.value.forEach((val: string) => {
subjectAttrs.push({ type: attr.type as any, value: val });
});
}
});
if (subjectAttrs.length > 0) {
setValue("subjectAttributes", subjectAttrs);
} else {
setValue("subjectAttributes", [{ type: "common_name", value: "" }]);
}
} }
if ( if (
templateData.subjectAlternativeNames && templateData.algorithms?.keyAlgorithm &&
Array.isArray(templateData.subjectAlternativeNames) templateData.algorithms.keyAlgorithm.length > 0
) { ) {
const templateSans: Array<{ type: "dns" | "ip" | "email" | "uri"; value: string }> = []; setAllowedKeyAlgorithms(templateData.algorithms.keyAlgorithm);
} else {
templateData.subjectAlternativeNames.forEach((sanPolicy) => { setAllowedKeyAlgorithms([]);
if (
(sanPolicy.include === "mandatory" ||
sanPolicy.include === "optional" ||
sanPolicy.include === "prohibit") &&
sanPolicy.value &&
sanPolicy.value.length > 0
) {
const typeMapping: Record<string, "dns" | "ip" | "email" | "uri"> = {
dns_name: "dns",
ip_address: "ip",
email: "email",
uri: "uri"
};
const mappedType = typeMapping[sanPolicy.type];
if (mappedType) {
sanPolicy.value.forEach((val: string) => {
templateSans.push({ type: mappedType, value: val });
});
}
}
});
if (templateSans.length > 0) {
setValue("subjectAltNames", templateSans);
}
} }
const resetKeyUsages = { if (templateData.validity?.max) {
[CertKeyUsage.DIGITAL_SIGNATURE]: false, setValue("ttl", templateData.validity.max);
[CertKeyUsage.KEY_ENCIPHERMENT]: false,
[CertKeyUsage.NON_REPUDIATION]: false,
[CertKeyUsage.DATA_ENCIPHERMENT]: false,
[CertKeyUsage.KEY_AGREEMENT]: false,
[CertKeyUsage.KEY_CERT_SIGN]: false,
[CertKeyUsage.CRL_SIGN]: false,
[CertKeyUsage.ENCIPHER_ONLY]: false,
[CertKeyUsage.DECIPHER_ONLY]: false
};
const resetExtendedKeyUsages = {
[CertExtendedKeyUsage.CLIENT_AUTH]: false,
[CertExtendedKeyUsage.CODE_SIGNING]: false,
[CertExtendedKeyUsage.EMAIL_PROTECTION]: false,
[CertExtendedKeyUsage.OCSP_SIGNING]: false,
[CertExtendedKeyUsage.SERVER_AUTH]: false,
[CertExtendedKeyUsage.TIMESTAMPING]: false
};
const templateToEnumMap = {
digital_signature: CertKeyUsage.DIGITAL_SIGNATURE,
digitalSignature: CertKeyUsage.DIGITAL_SIGNATURE,
key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT,
keyEncipherment: CertKeyUsage.KEY_ENCIPHERMENT,
non_repudiation: CertKeyUsage.NON_REPUDIATION,
nonRepudiation: CertKeyUsage.NON_REPUDIATION,
data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT,
dataEncipherment: CertKeyUsage.DATA_ENCIPHERMENT,
key_agreement: CertKeyUsage.KEY_AGREEMENT,
keyAgreement: CertKeyUsage.KEY_AGREEMENT,
key_cert_sign: CertKeyUsage.KEY_CERT_SIGN,
keyCertSign: CertKeyUsage.KEY_CERT_SIGN,
crl_sign: CertKeyUsage.CRL_SIGN,
cRLSign: CertKeyUsage.CRL_SIGN,
encipher_only: CertKeyUsage.ENCIPHER_ONLY,
encipherOnly: CertKeyUsage.ENCIPHER_ONLY,
decipher_only: CertKeyUsage.DECIPHER_ONLY,
decipherOnly: CertKeyUsage.DECIPHER_ONLY,
client_auth: CertExtendedKeyUsage.CLIENT_AUTH,
clientAuth: CertExtendedKeyUsage.CLIENT_AUTH,
server_auth: CertExtendedKeyUsage.SERVER_AUTH,
serverAuth: CertExtendedKeyUsage.SERVER_AUTH,
code_signing: CertExtendedKeyUsage.CODE_SIGNING,
codeSigning: CertExtendedKeyUsage.CODE_SIGNING,
email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION,
emailProtection: CertExtendedKeyUsage.EMAIL_PROTECTION,
ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING,
ocspSigning: CertExtendedKeyUsage.OCSP_SIGNING,
time_stamping: CertExtendedKeyUsage.TIMESTAMPING,
timestamping: CertExtendedKeyUsage.TIMESTAMPING,
timeStamping: CertExtendedKeyUsage.TIMESTAMPING
};
const currentKeyUsages = { ...resetKeyUsages };
if (templateData.keyUsages?.requiredUsages?.all) {
templateData.keyUsages.requiredUsages.all.forEach((usage: string) => {
const enumValue = (templateToEnumMap as any)[usage];
if (enumValue && enumValue in currentKeyUsages) {
(currentKeyUsages as any)[enumValue] = true;
}
});
} }
const currentExtendedKeyUsages = { ...resetExtendedKeyUsages }; const keyUsages: string[] = [];
if (templateData.extendedKeyUsages?.requiredUsages?.all) { if (templateData.keyUsages?.required) {
templateData.extendedKeyUsages.requiredUsages.all.forEach((usage: string) => { keyUsages.push(...templateData.keyUsages.required);
const enumValue = (templateToEnumMap as any)[usage];
if (enumValue && enumValue in currentExtendedKeyUsages) {
(currentExtendedKeyUsages as any)[enumValue] = true;
}
});
} }
if (templateData.keyUsages?.allowed) {
keyUsages.push(...templateData.keyUsages.allowed);
}
setAllowedKeyUsages(keyUsages);
setValue("keyUsages", currentKeyUsages); const extendedKeyUsages: string[] = [];
setValue("extendedKeyUsages", currentExtendedKeyUsages); if (templateData.extendedKeyUsages?.required) {
extendedKeyUsages.push(...templateData.extendedKeyUsages.required);
}
if (templateData.extendedKeyUsages?.allowed) {
extendedKeyUsages.push(...templateData.extendedKeyUsages.allowed);
}
setAllowedExtendedKeyUsages(extendedKeyUsages);
setRequiredKeyUsages(templateData.keyUsages?.required || []);
setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []);
const initialKeyUsages: Record<string, boolean> = {};
const initialExtendedKeyUsages: Record<string, boolean> = {};
(templateData.keyUsages?.required || []).forEach((usage: string) => {
initialKeyUsages[usage] = true;
});
(templateData.extendedKeyUsages?.required || []).forEach((usage: string) => {
initialExtendedKeyUsages[usage] = true;
});
setValue("keyUsages", initialKeyUsages);
setValue("extendedKeyUsages", initialExtendedKeyUsages);
} }
}, [templateData, selectedProfile, setValue]); }, [templateData, selectedProfile, setValue, popUp?.certificateIssuance?.isOpen]);
useEffect(() => { useEffect(() => {
if (cert) { if (cert) {
@@ -451,83 +297,138 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
} }
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]); }, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const onFormSubmit = async ({ const getAttributeValue = useCallback(
profileId, (subjectAttributes: typeof schema._type.subjectAttributes, type: string) => {
subjectAttributes, const foundAttr = subjectAttributes.find((attr) => attr.type === type);
subjectAltNames, return foundAttr?.value || "";
ttl, },
signatureAlgorithm, []
keyAlgorithm, );
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentProject?.slug) return;
const getAttributeValue = (type: string) => { const formatSubjectAltNames = useCallback(
const foundAttr = subjectAttributes.find((attr) => attr.type === type); (subjectAltNames: typeof schema._type.subjectAltNames) => {
return foundAttr?.value || ""; return subjectAltNames
}; .filter((san) => san.value.trim())
.map((san) => san.value.trim())
.join(", ");
},
[]
);
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ const filterUsages = useCallback(<T extends Record<string, boolean>>(usages: T) => {
profileId, return Object.entries(usages)
projectSlug: currentProject.slug, .filter(([, value]) => value)
commonName: getAttributeValue("common_name"), .map(([key]) => key);
subjectAltNames: subjectAltNames }, []);
.filter((san) => san.value.trim())
.map((san) => san.value.trim())
.join(", "),
ttl,
signatureAlgorithm: (() => {
const frontendToBackendSigAlg: Record<string, string> = {
"RSA-SHA256": "RSA-SHA256",
"RSA-SHA384": "RSA-SHA384",
"RSA-SHA512": "RSA-SHA512",
"ECDSA-SHA256": "ECDSA-SHA256",
"ECDSA-SHA384": "ECDSA-SHA384",
"ECDSA-SHA512": "ECDSA-SHA512"
};
return signatureAlgorithm
? frontendToBackendSigAlg[signatureAlgorithm] || signatureAlgorithm
: undefined;
})(),
keyAlgorithm: (() => {
const frontendToBackendKeyAlg: Record<string, string> = {
RSA_2048: "RSA_2048",
RSA_3072: "RSA_3072",
RSA_4096: "RSA_4096",
EC_prime256v1: "EC_prime256v1",
EC_secp384r1: "EC_secp384r1"
};
return keyAlgorithm ? frontendToBackendKeyAlg[keyAlgorithm] || keyAlgorithm : undefined;
})(),
keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertKeyUsage),
extendedKeyUsages: Object.entries(extendedKeyUsages)
.filter(([, value]) => value)
.map(([key]) => key as CertExtendedKeyUsage)
});
reset(); const onFormSubmit = useCallback(
async ({
profileId: formProfileId,
subjectAttributes,
subjectAltNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentProject?.slug) {
createNotification({
text: "Project not found. Please refresh and try again.",
type: "error"
});
return;
}
setCertificateDetails({ if (!formProfileId) {
serialNumber, createNotification({
certificate, text: "Please select a certificate profile.",
certificateChain, type: "error"
privateKey });
}); return;
}
createNotification({ const commonName = getAttributeValue(subjectAttributes, "common_name");
text: "Successfully created certificate", if (!commonName.trim()) {
type: "success" createNotification({
}); text: "Common name is required.",
} catch (err) { type: "error"
console.error(err); });
createNotification({ return;
text: "Failed to create certificate", }
type: "error"
}); const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate(
{
profileId: formProfileId,
projectSlug: currentProject.slug,
commonName,
subjectAltNames: formatSubjectAltNames(subjectAltNames),
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
}
);
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
} catch (err) {
console.error("Certificate creation failed:", err);
const errorMessage =
err instanceof Error
? err.message
: "An unexpected error occurred while creating the certificate";
createNotification({
text: `Failed to create certificate: ${errorMessage}`,
type: "error"
});
}
},
[
currentProject?.slug,
createCertificate,
reset,
getAttributeValue,
formatSubjectAltNames,
filterUsages
]
);
const getModalTitle = () => {
if (certificateDetails) return "Certificate Created Successfully";
if (cert) return "Certificate Details";
return "Issue New Certificate";
};
const getModalSubTitle = () => {
if (certificateDetails) return "Certificate has been successfully created and is ready for use";
if (cert) return "View certificate information";
return "Issue a new certificate using a certificate profile";
};
const getSanPlaceholder = (sanType: string) => {
switch (sanType) {
case "dns":
return "example.com or *.example.com";
case "ip":
return "192.168.1.1";
case "email":
return "[email protected]";
case "uri":
return "https://example.com";
default:
return "Enter value";
} }
}; };
@@ -536,26 +437,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
isOpen={popUp?.certificateIssuance?.isOpen} isOpen={popUp?.certificateIssuance?.isOpen}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("certificateIssuance", isOpen); handlePopUpToggle("certificateIssuance", isOpen);
setCertificateDetails(null); if (!isOpen) {
reset(); resetAllState();
}
}} }}
> >
<ModalContent <ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
title={
certificateDetails
? "Certificate Created Successfully"
: cert
? "Certificate Details"
: "Issue New Certificate"
}
subTitle={
certificateDetails
? "Certificate has been successfully created and is ready for use"
: cert
? "View certificate information"
: "Issue a new certificate using a certificate profile"
}
>
{certificateDetails && ( {certificateDetails && (
<CertificateContent <CertificateContent
serialNumber={certificateDetails.serialNumber} serialNumber={certificateDetails.serialNumber}
@@ -567,10 +454,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{cert && ( {cert && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h4 className="text-mineshaft-300 text-sm font-medium">Certificate Details</h4> <h4 className="text-sm font-medium text-mineshaft-300">Certificate Details</h4>
<p className="text-mineshaft-400 text-sm">Serial Number: {cert.serialNumber}</p> <p className="text-sm text-mineshaft-400">Serial Number: {cert.serialNumber}</p>
<p className="text-mineshaft-400 text-sm">Common Name: {cert.commonName}</p> <p className="text-sm text-mineshaft-400">Common Name: {cert.commonName}</p>
<p className="text-mineshaft-400 text-sm">Status: {cert.status}</p> <p className="text-sm text-mineshaft-400">Status: {cert.status}</p>
</div> </div>
</div> </div>
)} )}
@@ -592,9 +479,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
className="text-center" className="text-center"
content={ content={
<span> <span>
Certificate profiles define the policies and enrollment methods for Certificate profiles define the policies and enrollment methods
certificate issuance. The selected profile will enforce validation for certificate issuance. The selected profile will enforce
rules and determine the CA used for signing. validation rules and determine the CA used for signing.
</span> </span>
} }
> >
@@ -629,7 +516,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{(selectedProfile || profileId) && ( {(selectedProfile || profileId) && (
<> <>
<Controller <Controller
control={control} control={control}
name="subjectAttributes" name="subjectAttributes"
@@ -642,7 +528,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
> >
<div className="space-y-2"> <div className="space-y-2">
{value.map((attr, index) => ( {value.map((attr, index) => (
<div key={`attr-${index}`} className="flex items-start gap-2"> // eslint-disable-next-line react/no-array-index-key
<div key={`subject-attr-${index}`} className="flex items-center gap-2">
<Select <Select
value={attr.type} value={attr.type}
onValueChange={(newType) => { onValueChange={(newType) => {
@@ -710,7 +597,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
> >
<div className="space-y-2"> <div className="space-y-2">
{value.map((san, index) => ( {value.map((san, index) => (
<div key={`san-${index}`} className="flex items-start gap-2"> // eslint-disable-next-line react/no-array-index-key
<div
key={`subject-alt-name-${index}`}
className="flex items-center gap-2"
>
<Select <Select
value={san.type} value={san.type}
onValueChange={(newType) => { onValueChange={(newType) => {
@@ -735,15 +626,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
newValue[index] = { ...san, value: e.target.value }; newValue[index] = { ...san, value: e.target.value };
onChange(newValue); onChange(newValue);
}} }}
placeholder={ placeholder={getSanPlaceholder(san.type)}
san.type === "dns"
? "example.com or *.example.com"
: san.type === "ip"
? "192.168.1.1"
: san.type === "email"
? "[email protected]"
: "https://example.com"
}
className="flex-1" className="flex-1"
/> />
<IconButton <IconButton
@@ -807,13 +690,14 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{...field} {...field}
onValueChange={(e) => onChange(e)} onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
placeholder="Use template default" placeholder={
availableSignatureAlgorithms.length > 0
? "Select signature algorithm"
: "No algorithms available"
}
position="popper" position="popper"
> >
{SIGNATURE_ALGORITHMS_OPTIONS.filter((algorithm) => { {availableSignatureAlgorithms.map((algorithm) => (
if (allowedSignatureAlgorithms.length === 0) return true;
return allowedSignatureAlgorithms.includes(algorithm.value);
}).map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}> <SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label} {algorithm.label}
</SelectItem> </SelectItem>
@@ -839,19 +723,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{...field} {...field}
onValueChange={(e) => onChange(e)} onValueChange={(e) => onChange(e)}
className="w-full" className="w-full"
placeholder="Use template default" placeholder={
availableKeyAlgorithms.length > 0
? "Select key algorithm"
: "No algorithms available"
}
position="popper" position="popper"
> >
{certKeyAlgorithms {availableKeyAlgorithms.map((algorithm) => (
.filter((algorithm) => { <SelectItem key={algorithm.value} value={algorithm.value}>
if (allowedKeyAlgorithms.length === 0) return true; {algorithm.label}
return allowedKeyAlgorithms.includes(algorithm.value); </SelectItem>
}) ))}
.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select> </Select>
</FormControl> </FormControl>
)} )}
@@ -863,88 +746,80 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<AccordionItem value="key-usages"> <AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger> <AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2"> <div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGES_OPTIONS.filter(({ value }) => { {filteredKeyUsages.map(({ label, value }) => {
if (allowedKeyUsages.length === 0) return true; const isRequired = requiredKeyUsages.includes(value);
const templateToEnumMap = { return (
digital_signature: CertKeyUsage.DIGITAL_SIGNATURE, <Controller
key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT, key={label}
non_repudiation: CertKeyUsage.NON_REPUDIATION, control={control}
data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT, name={`keyUsages.${value}` as any}
key_agreement: CertKeyUsage.KEY_AGREEMENT, render={({ field }) => (
key_cert_sign: CertKeyUsage.KEY_CERT_SIGN, <div className="flex items-center space-x-3">
crl_sign: CertKeyUsage.CRL_SIGN, <Checkbox
encipher_only: CertKeyUsage.ENCIPHER_ONLY, id={`key-usage-${value}`}
decipher_only: CertKeyUsage.DECIPHER_ONLY isChecked={field.value || false}
}; onCheckedChange={(checked) => {
return allowedKeyUsages.some( if (!isRequired) {
(allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value field.onChange(checked);
); }
}).map(({ label, value }) => ( }}
<Controller isDisabled={isRequired}
key={label} />
control={control} <div className="flex items-center gap-2">
name={`keyUsages.${value}` as any} <FormLabel
render={({ field }) => ( id={`key-usage-${value}`}
<div className="flex items-center space-x-3"> className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
<Checkbox label={label}
id={`key-usage-${value}`} />
isChecked={field.value || false} {isRequired && <span className="text-xs">(Required)</span>}
onCheckedChange={(checked) => field.onChange(checked)} </div>
/> </div>
<FormLabel )}
id={`key-usage-${value}`} />
className="text-mineshaft-300 cursor-pointer text-sm" );
label={label} })}
/> </div>
</div>
)}
/>
))}
</div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="extended-key-usages"> <AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger> <AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2"> <div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => { {filteredExtendedKeyUsages.map(({ label, value }) => {
if (allowedExtendedKeyUsages.length === 0) return true; const isRequired = requiredExtendedKeyUsages.includes(value);
const templateToEnumMap = { return (
client_auth: CertExtendedKeyUsage.CLIENT_AUTH, <Controller
server_auth: CertExtendedKeyUsage.SERVER_AUTH, key={label}
code_signing: CertExtendedKeyUsage.CODE_SIGNING, control={control}
email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION, name={`extendedKeyUsages.${value}` as any}
ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING, render={({ field }) => (
time_stamping: CertExtendedKeyUsage.TIMESTAMPING, <div className="flex items-center space-x-3">
timestamping: CertExtendedKeyUsage.TIMESTAMPING <Checkbox
}; id={`ext-key-usage-${value}`}
return allowedExtendedKeyUsages.some( isChecked={field.value || false}
(allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value onCheckedChange={(checked) => {
); if (!isRequired) {
}).map(({ label, value }) => ( field.onChange(checked);
<Controller }
key={label} }}
control={control} isDisabled={isRequired}
name={`extendedKeyUsages.${value}` as any} />
render={({ field }) => ( <div className="flex items-center gap-2">
<div className="flex items-center space-x-3"> <FormLabel
<Checkbox id={`ext-key-usage-${value}`}
id={`ext-key-usage-${value}`} className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
isChecked={field.value || false} label={label}
onCheckedChange={(checked) => field.onChange(checked)} />
/> {isRequired && <span className="text-xs">(Required)</span>}
<FormLabel </div>
id={`ext-key-usage-${value}`} </div>
className="text-mineshaft-300 cursor-pointer text-sm" )}
label={label} />
/> );
</div> })}
)} </div>
/>
))}
</div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
@@ -964,7 +839,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<Button <Button
colorSchema="secondary" colorSchema="secondary"
variant="plain" variant="plain"
onClick={() => handlePopUpToggle("certificateIssuance", false)} onClick={() => {
handlePopUpToggle("certificateIssuance", false);
}}
> >
Cancel Cancel
</Button> </Button>
@@ -87,16 +87,16 @@ export const PkiSubscriberSection = () => {
const subscriberName = subscriberStatusData?.subscriberName || ""; const subscriberName = subscriberStatusData?.subscriberName || "";
return ( return (
<div className="border-mineshaft-600 bg-mineshaft-900 mb-6 rounded-lg border p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-mineshaft-100 text-xl font-medium">Subscribers</p> <p className="text-xl font-medium text-mineshaft-100">Subscribers</p>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/pki/subscribers" href="https://infisical.com/docs/documentation/platform/pki/subscribers"
> >
<span className="border-mineshaft-500 bg-mineshaft-600 text-mineshaft-200 hover:border-primary/40 hover:bg-primary/10 flex w-max cursor-pointer items-center rounded-md border px-4 py-2 duration-200 hover:text-white"> <span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "} Documentation{" "}
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
@@ -112,32 +112,30 @@ export const PkiTemplateListPage = () => {
/> />
</div> </div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
{ {subscription?.pkiLegacyTemplates && (
subscription?.pkiLegacyTemplates && ( <div className="mb-4 flex justify-between">
<div className="mb-4 flex justify-between"> <p className="text-xl font-medium text-mineshaft-100">Templates</p>
<p className="text-xl font-medium text-mineshaft-100">Templates</p> <div className="flex w-full justify-end">
<div className="flex w-full justify-end"> <ProjectPermissionCan
<ProjectPermissionCan I={ProjectPermissionPkiTemplateActions.Create}
I={ProjectPermissionPkiTemplateActions.Create} a={ProjectPermissionSub.CertificateTemplates}
a={ProjectPermissionSub.CertificateTemplates} >
> {(isAllowed) => (
{(isAllowed) => ( <Button
<Button colorSchema="primary"
colorSchema="primary" type="submit"
type="submit" leftIcon={<FontAwesomeIcon icon={faPlus} />}
leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => handlePopUpOpen("certificateTemplate")}
onClick={() => handlePopUpOpen("certificateTemplate")} isDisabled={!isAllowed}
isDisabled={!isAllowed} className="ml-4"
className="ml-4" >
> Add Template
Add Template </Button>
</Button> )}
)} </ProjectPermissionCan>
</ProjectPermissionCan> </div>
</div> </div>
</div> )}
)
}
<TableContainer> <TableContainer>
<Table> <Table>
<THead> <THead>
@@ -165,7 +165,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
if (isEdit) { if (isEdit) {
const updateData: any = { const updateData: any = {
profileId: profile.id, profileId: profile.id,
name: data.slug, slug: data.slug,
description: data.description description: data.description
}; };
@@ -240,7 +240,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} placeholder="your-profile-name" isDisabled={Boolean(isEdit)} /> <Input {...field} placeholder="your-profile-name" />
</FormControl> </FormControl>
)} )}
/> />
@@ -319,7 +319,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
> >
{certificateTemplates.map((template) => ( {certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}> <SelectItem key={template.id} value={template.id}>
{template.slug} {template.name}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -376,17 +376,17 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="estConfig.disableBootstrapCaValidation" name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="border-mineshaft-600 bg-mineshaft-900 flex items-center gap-3 rounded-md border p-4"> <div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<Checkbox <Checkbox
id="disableBootstrapCaValidation" id="disableBootstrapCaValidation"
isChecked={value} isChecked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
/> />
<div className="space-y-1"> <div className="space-y-1">
<span className="text-mineshaft-100 text-sm font-medium"> <span className="text-sm font-medium text-mineshaft-100">
Disable Bootstrap CA Validation Disable Bootstrap CA Validation
</span> </span>
<p className="text-bunker-300 text-xs"> <p className="text-xs text-bunker-300">
Skip CA certificate validation during EST bootstrap phase Skip CA certificate validation during EST bootstrap phase
</p> </p>
</div> </div>
@@ -433,7 +433,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
rows={6} rows={6}
className="w-full font-mono text-xs" className="w-full font-mono text-xs"
/> />
<p className="text-bunker-400 text-xs"> <p className="text-xs text-bunker-400">
Paste the complete CA certificate chain in PEM format Paste the complete CA certificate chain in PEM format
</p> </p>
</div> </div>
@@ -1,7 +1,17 @@
/* eslint-disable no-nested-ternary */ /* eslint-disable no-nested-ternary */
import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { useCallback } from "react";
import {
faCheck,
faCircleInfo,
faCopy,
faEdit,
faEllipsis,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { import {
Badge, Badge,
DropdownMenu, DropdownMenu,
@@ -18,12 +28,10 @@ import {
ProjectPermissionCertificateProfileActions, ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetCaById } from "@app/hooks/api/ca/queries"; import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { usePopUp, useToggle } from "@app/hooks";
import { createNotification } from "@app/components/notifications";
import { useCallback } from "react";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
interface Props { interface Props {
@@ -37,9 +45,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const { data: caData } = useGetCaById(profile.caId); const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
"certificateIssuance"
] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false); const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -100,15 +106,15 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)} )}
</div> </div>
</Td> </Td>
<Td className="text-center">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td> <Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-center"> <Td className="text-start">
<span className="text-sm text-mineshaft-300"> <span className="text-sm text-mineshaft-300">
{caData?.friendlyName || caData?.commonName || profile.caId} {caData?.friendlyName || caData?.commonName || profile.caId}
</span> </span>
</Td> </Td>
<Td> <Td>
<span className="text-sm text-mineshaft-300"> <span className="text-sm text-mineshaft-300">
{templateData?.slug || profile.certificateTemplateId} {templateData?.name || profile.certificateTemplateId}
</span> </span>
</Td> </Td>
<Td> <Td>
@@ -176,19 +182,17 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
Edit Profile Edit Profile
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{ {canIssueCertificate && (
canIssueCertificate && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); handlePopUpToggle("certificateIssuance");
handlePopUpToggle("certificateIssuance"); }}
}} icon={<FontAwesomeIcon icon={faPlus} />}
icon={<FontAwesomeIcon icon={faPlus} />} >
> Issue Certificate
Issue Certificate </DropdownMenuItem>
</DropdownMenuItem> )}
)
}
{canDeleteProfile && ( {canDeleteProfile && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -202,7 +206,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} profileId={profile.id}/> <CertificateIssuanceModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
profileId={profile.id}
/>
</Td> </Td>
</Tr> </Tr>
); );
@@ -54,7 +54,7 @@ export const CertificateTemplatesV2Tab = () => {
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelectedTemplate(null); setSelectedTemplate(null);
createNotification({ createNotification({
text: `Certificate template "${selectedTemplate.slug}" deleted successfully`, text: `Certificate template "${selectedTemplate.name}" deleted successfully`,
type: "success" type: "success"
}); });
} catch (error: any) { } catch (error: any) {
@@ -103,12 +103,12 @@ export const CertificateTemplatesV2Tab = () => {
<DeleteActionModal <DeleteActionModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
title={`Delete Certificate Template ${selectedTemplate.slug}?`} title={`Delete Certificate Template ${selectedTemplate.name}?`}
onChange={(isOpen) => { onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen); setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedTemplate(null); if (!isOpen) setSelectedTemplate(null);
}} }}
deleteKey={selectedTemplate.slug} deleteKey={selectedTemplate.name}
onDeleteApproved={handleDeleteConfirm} onDeleteApproved={handleDeleteConfirm}
/> />
</> </>
@@ -75,61 +75,64 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
</Td> </Td>
</Tr> </Tr>
)} )}
{!isLoading && templates && templates.length > 0 && templates.map((template) => ( {!isLoading &&
<Tr templates &&
key={template.id} templates.length > 0 &&
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700" templates.map((template) => (
> <Tr
<Td> key={template.id}
<div className="flex items-center gap-2"> className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
<div className="font-medium">{template.slug}</div> >
{template.description && ( <Td>
<Tooltip content={template.description}> <div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <div className="font-medium">{template.name}</div>
</Tooltip> {template.description && (
)} <Tooltip content={template.description}>
</div> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Td>
<Td>
<span className="text-sm text-bunker-300">{formatDate(template.createdAt)}</span>
</Td>
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip> </Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{canEditTemplate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditTemplate(template);
}}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Template
</DropdownMenuItem>
)} )}
{canDeleteTemplate && ( </div>
<DropdownMenuItem </Td>
onClick={(e) => { <Td>
e.stopPropagation(); <span className="text-sm text-bunker-300">{formatDate(template.createdAt)}</span>
onDeleteTemplate(template); </Td>
}} <Td className="text-right">
icon={<FontAwesomeIcon icon={faTrash} />} <DropdownMenu>
> <DropdownMenuTrigger asChild className="rounded-lg">
Delete Template <div className="hover:text-primary-400 data-[state=open]:text-primary-400">
</DropdownMenuItem> <Tooltip content="More options">
)} <FontAwesomeIcon size="lg" icon={faEllipsis} />
</DropdownMenuContent> </Tooltip>
</DropdownMenu> </div>
</Td> </DropdownMenuTrigger>
</Tr> <DropdownMenuContent align="start" className="p-1">
))} {canEditTemplate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEditTemplate(template);
}}
icon={<FontAwesomeIcon icon={faEdit} />}
>
Edit Template
</DropdownMenuItem>
)}
{canDeleteTemplate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate(template);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Template
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
@@ -26,10 +26,16 @@ export enum CertExtendedKeyUsageType {
TIME_STAMPING = "time_stamping" TIME_STAMPING = "time_stamping"
} }
export enum CertIncludeType { export enum CertAttributeRule {
MANDATORY = "mandatory", ALLOW = "allow",
OPTIONAL = "optional", DENY = "deny",
PROHIBIT = "prohibit" REQUIRE = "require"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
} }
export enum CertDurationUnit { export enum CertDurationUnit {
@@ -39,7 +45,9 @@ export enum CertDurationUnit {
} }
export enum CertSubjectAttributeType { export enum CertSubjectAttributeType {
COMMON_NAME = "common_name" COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
} }
export const formatSANType = (type: CertSubjectAlternativeNameType): string => { export const formatSANType = (type: CertSubjectAlternativeNameType): string => {
@@ -104,61 +112,117 @@ export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string
export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => { export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => {
switch (type) { switch (type) {
case CertSubjectAttributeType.COMMON_NAME: case CertSubjectAttributeType.COMMON_NAME:
return "Common Name"; return "Common Name (CN)";
case CertSubjectAttributeType.ORGANIZATION:
return "Organization";
case CertSubjectAttributeType.COUNTRY:
return "Country";
default: default:
return type; return type;
} }
}; };
export const formatIncludeType = (include: CertIncludeType): string => { export const formatAttributeRule = (rule: CertAttributeRule): string => {
switch (include) { switch (rule) {
case CertIncludeType.MANDATORY: case CertAttributeRule.ALLOW:
return "Mandatory"; return "Allow";
case CertIncludeType.OPTIONAL: case CertAttributeRule.DENY:
return "Optional"; return "Deny";
case CertIncludeType.PROHIBIT: case CertAttributeRule.REQUIRE:
return "Prohibit"; return "Require";
default: default:
return include; return rule;
} }
}; };
export const mapLegacySANTypeToStandard = (type: string): CertSubjectAlternativeNameType => { export const formatSanEffect = (effect: CertSanEffect): string => {
switch (type) { switch (effect) {
case "dns": case CertSanEffect.ALLOW:
case "dns_name": return "Allow";
return CertSubjectAlternativeNameType.DNS_NAME; case CertSanEffect.DENY:
case "ip": return "Deny";
case "ip_address": case CertSanEffect.REQUIRE:
return CertSubjectAlternativeNameType.IP_ADDRESS; return "Require";
case "email":
return CertSubjectAlternativeNameType.EMAIL;
case "uri":
case "url":
return CertSubjectAlternativeNameType.URI;
default: default:
throw new Error(`Unknown SAN type: ${type}`); return effect;
} }
}; };
export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => {
switch (type) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "dns";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "ip";
case CertSubjectAlternativeNameType.EMAIL:
return "email";
case CertSubjectAlternativeNameType.URI:
return "uri";
default:
return type;
}
};
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); 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 DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); 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 SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS = ["optional", "prohibit"] as const;
export const SAN_INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
export const USAGE_STATES = {
REQUIRED: "required",
OPTIONAL: "optional"
} as const;
export type UsageState = typeof USAGE_STATES[keyof typeof USAGE_STATES] | undefined;
export const TEMPLATE_SIGNATURE_ALGORITHMS = [
"SHA256-RSA",
"SHA384-RSA",
"SHA512-RSA",
"SHA256-ECDSA",
"SHA384-ECDSA",
"SHA512-ECDSA"
] as const;
export const TEMPLATE_KEY_ALGORITHMS = [
"RSA-2048",
"RSA-3072",
"RSA-4096",
"ECDSA-P256",
"ECDSA-P384"
] as const;
// API format algorithm constants
export const API_SIGNATURE_ALGORITHMS = [
"RSA-SHA256",
"RSA-SHA384",
"RSA-SHA512",
"ECDSA-SHA256",
"ECDSA-SHA384",
"ECDSA-SHA512"
] as const;
export const API_KEY_ALGORITHMS = [
"RSA_2048",
"RSA_3072",
"RSA_4096",
"EC_prime256v1",
"EC_secp384r1"
] as const;
// Mapping functions between template and API formats
export 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;
};
export 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"
};
return mapping[templateFormat] || templateFormat;
};
@@ -1,152 +1,159 @@
import { Checkbox } from "@app/components/v2"; import React from "react";
import { Select, SelectItem } from "@app/components/v2";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertKeyUsageType, CertKeyUsageType,
EXTENDED_KEY_USAGE_OPTIONS,
formatExtendedKeyUsage, formatExtendedKeyUsage,
formatKeyUsage, formatKeyUsage,
EXTENDED_KEY_USAGE_OPTIONS,
KEY_USAGE_OPTIONS KEY_USAGE_OPTIONS
} from "./certificate-constants"; } from "./certificate-constants";
type UsageState = "mandatory" | "optional" | undefined; type KeyUsagePolicy = "allow" | "require" | "deny" | "none";
type ThreeStateCheckboxProps = { interface KeyUsagesSectionProps {
value: UsageState; watchedKeyUsages: {
onChange: (newValue: UsageState) => void; requiredUsages: CertKeyUsageType[];
label: string; optionalUsages: CertKeyUsageType[];
id: string;
};
const ThreeStateCheckbox = ({ value, onChange, label, id }: ThreeStateCheckboxProps) => {
const handleClick = () => {
if (value === undefined) {
onChange("optional");
} else if (value === "optional") {
onChange("mandatory");
} else {
onChange(undefined);
}
}; };
watchedExtendedKeyUsages: {
const getCheckboxState = () => { requiredUsages: CertExtendedKeyUsageType[];
if (value) return true; optionalUsages: CertExtendedKeyUsageType[];
return false;
}; };
onKeyUsagesChange: (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => void;
onExtendedKeyUsagesChange: (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => void;
}
const getIndeterminateState = () => { export const KeyUsagesSection: React.FC<KeyUsagesSectionProps> = ({
return value === "optional"; watchedKeyUsages,
}; watchedExtendedKeyUsages,
const getStateLabel = () => {
if (value === "mandatory") return " (Mandatory)";
if (value === "optional") return " (Optional)";
return "";
};
return (
<div className="flex items-center space-x-3">
<Checkbox
id={id}
isChecked={getCheckboxState()}
isIndeterminate={getIndeterminateState()}
onCheckedChange={handleClick}
/>
<label
htmlFor={id}
className="text-mineshaft-200 cursor-pointer text-sm font-medium"
>
{label}
{value && (
<span className="text-mineshaft-400 text-xs ml-1">
{getStateLabel()}
</span>
)}
</label>
</div>
);
};
type KeyUsagesSectionProps = {
watchedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
watchedExtendedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
onKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
onExtendedKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
};
export const KeyUsagesSection = ({
watchedKeyUsages = { requiredUsages: [], optionalUsages: [] },
watchedExtendedKeyUsages = { requiredUsages: [], optionalUsages: [] },
onKeyUsagesChange, onKeyUsagesChange,
onExtendedKeyUsagesChange onExtendedKeyUsagesChange
}: KeyUsagesSectionProps) => { }) => {
const getUsageState = (usage: string, data: { requiredUsages?: string[]; optionalUsages?: string[] }): UsageState => { const getKeyUsagePolicy = (usage: CertKeyUsageType): KeyUsagePolicy => {
if (data.requiredUsages?.includes(usage)) return "mandatory"; if (watchedKeyUsages.requiredUsages.includes(usage)) return "require";
if (data.optionalUsages?.includes(usage)) return "optional"; if (watchedKeyUsages.optionalUsages.includes(usage)) return "allow";
return undefined; return "deny";
}; };
const handleKeyUsageChange = (usage: CertKeyUsageType, newState: UsageState) => { const getExtendedKeyUsagePolicy = (usage: CertExtendedKeyUsageType): KeyUsagePolicy => {
const currentRequired = watchedKeyUsages.requiredUsages || []; if (watchedExtendedKeyUsages.requiredUsages.includes(usage)) return "require";
const currentOptional = watchedKeyUsages.optionalUsages || []; if (watchedExtendedKeyUsages.optionalUsages.includes(usage)) return "allow";
return "deny";
};
let newRequired = currentRequired.filter(u => u !== usage); const handleKeyUsagePolicyChange = (usage: CertKeyUsageType, policy: KeyUsagePolicy) => {
let newOptional = currentOptional.filter(u => u !== usage); const newRequired = watchedKeyUsages.requiredUsages.filter((u) => u !== usage);
const newOptional = watchedKeyUsages.optionalUsages.filter((u) => u !== usage);
if (newState === "mandatory") { if (policy === "require") {
newRequired = [...newRequired, usage]; newRequired.push(usage);
} else if (newState === "optional") { } else if (policy === "allow") {
newOptional = [...newOptional, usage]; newOptional.push(usage);
} }
onKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional }); onKeyUsagesChange({
requiredUsages: newRequired,
optionalUsages: newOptional
});
}; };
const handleExtendedKeyUsageChange = (usage: CertExtendedKeyUsageType, newState: UsageState) => { const handleExtendedKeyUsagePolicyChange = (
const currentRequired = watchedExtendedKeyUsages.requiredUsages || []; usage: CertExtendedKeyUsageType,
const currentOptional = watchedExtendedKeyUsages.optionalUsages || []; policy: KeyUsagePolicy
) => {
const newRequired = watchedExtendedKeyUsages.requiredUsages.filter((u) => u !== usage);
const newOptional = watchedExtendedKeyUsages.optionalUsages.filter((u) => u !== usage);
let newRequired = currentRequired.filter(u => u !== usage); if (policy === "require") {
let newOptional = currentOptional.filter(u => u !== usage); newRequired.push(usage);
} else if (policy === "allow") {
if (newState === "mandatory") { newOptional.push(usage);
newRequired = [...newRequired, usage];
} else if (newState === "optional") {
newOptional = [...newOptional, usage];
} }
onExtendedKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional }); onExtendedKeyUsagesChange({
requiredUsages: newRequired,
optionalUsages: newOptional
});
}; };
const keyUsagePolicyOptions = [
{ value: "deny", label: "Deny" },
{ value: "allow", label: "Allow" },
{ value: "require", label: "Require" }
];
const extendedKeyUsagePolicyOptions = [
{ value: "deny", label: "Deny" },
{ value: "allow", label: "Allow" },
{ value: "require", label: "Require" }
];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="space-y-3"> {/* Key Usages */}
<h3 className="text-mineshaft-200 text-sm font-medium">Key Usages</h3> <div className="space-y-4">
<div className="grid grid-cols-2 gap-2 pl-2"> <h4 className="text-sm font-medium text-bunker-200">Key Usages</h4>
{KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox <div className="grid grid-cols-2 gap-4">
key={usage} {KEY_USAGE_OPTIONS.map((usage) => {
id={`key-usage-${usage}`} const policy = getKeyUsagePolicy(usage);
label={formatKeyUsage(usage)} return (
value={getUsageState(usage, watchedKeyUsages)} <div key={usage} className="flex items-center justify-between">
onChange={(newState) => handleKeyUsageChange(usage, newState)} <span className="text-sm text-bunker-200">{formatKeyUsage(usage)}</span>
/> <Select
))} value={policy}
onValueChange={(value) =>
handleKeyUsagePolicyChange(usage, value as KeyUsagePolicy)
}
className="w-32"
>
{keyUsagePolicyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
})}
</div> </div>
</div> </div>
<div className="space-y-3"> {/* Extended Key Usages */}
<h3 className="text-mineshaft-200 text-sm font-medium">Extended Key Usages</h3> <div className="space-y-4">
<div className="grid grid-cols-2 gap-2 pl-2"> <h4 className="text-sm font-medium text-bunker-200">Extended Key Usages</h4>
{EXTENDED_KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox <div className="grid grid-cols-2 gap-4">
key={usage} {EXTENDED_KEY_USAGE_OPTIONS.map((usage) => {
id={`ext-key-usage-${usage}`} const policy = getExtendedKeyUsagePolicy(usage);
label={formatExtendedKeyUsage(usage)} return (
value={getUsageState(usage, watchedExtendedKeyUsages)} <div key={usage} className="flex items-center justify-between">
onChange={(newState) => handleExtendedKeyUsageChange(usage, newState)} <span className="text-sm text-bunker-200">{formatExtendedKeyUsage(usage)}</span>
/> <Select
))} value={policy}
onValueChange={(value) =>
handleExtendedKeyUsagePolicyChange(usage, value as KeyUsagePolicy)
}
className="w-32"
>
{extendedKeyUsagePolicyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</Select>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
@@ -3,93 +3,121 @@ import { z } from "zod";
import { import {
CertDurationUnit, CertDurationUnit,
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType, CertSubjectAlternativeNameType,
CertSubjectAttributeType CertSubjectAttributeType,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS,
SAN_INCLUDE_OPTIONS
} from "./certificate-constants"; } from "./certificate-constants";
export const attributeSchema = z.object({ export const uiAttributeSchema = z.object({
type: z.nativeEnum(CertSubjectAttributeType), type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType), include: z.enum(SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS),
value: z.array(z.string().min(1, "Value cannot be empty")).optional() value: z.array(z.string().min(1, "Value cannot be empty"))
}); });
export const sanSchema = z.object({ export const uiSanSchema = z.object({
type: z.nativeEnum(CertSubjectAlternativeNameType), type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.nativeEnum(CertIncludeType), include: z.enum(SAN_INCLUDE_OPTIONS),
value: z.array(z.string().min(1, "Value cannot be empty")).optional() value: z.array(z.string().min(1, "Value cannot be empty"))
});
export const uiKeyUsagesSchema = z.object({
requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)),
optionalUsages: z.array(z.nativeEnum(CertKeyUsageType))
});
export const uiExtendedKeyUsagesSchema = z.object({
requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)),
optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType))
});
export const uiValiditySchema = z.object({
maxDuration: z.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.nativeEnum(CertDurationUnit)
})
});
export const uiSignatureAlgorithmSchema = z.object({
allowedAlgorithms: z.array(z.string()).optional(),
defaultAlgorithm: z.string().optional()
});
export const uiKeyAlgorithmSchema = z.object({
allowedKeyTypes: z.array(z.string()).optional(),
defaultKeyType: z.string().optional()
}); });
export const templateSchema = z.object({ export const templateSchema = z.object({
slug: z.string().trim().min(1, "Template name is required"), slug: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(), description: z.string().optional(),
attributes: z.array(attributeSchema).optional().refine((attributes) => { attributes: z.array(uiAttributeSchema).optional(),
if (!attributes) return true; subjectAlternativeNames: z.array(uiSanSchema).optional(),
keyUsages: uiKeyUsagesSchema.optional(),
extendedKeyUsages: uiExtendedKeyUsagesSchema.optional(),
validity: uiValiditySchema.optional(),
signatureAlgorithm: uiSignatureAlgorithmSchema.optional(),
keyAlgorithm: uiKeyAlgorithmSchema.optional()
});
const attributesByType = attributes.reduce((acc, attr) => { export type TemplateFormData = z.infer<typeof templateSchema>;
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push(attr);
return acc;
}, {} as Record<string, typeof attributes>);
for (const [, attrs] of Object.entries(attributesByType)) { export const apiSubjectSchema = z
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory'); .object({
type: z.nativeEnum(CertSubjectAttributeType),
allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().min(1, "Value cannot be empty")).optional()
})
.refine((data) => data.allowed || data.required || data.denied, {
message: "At least one allowed, required, or denied value must be provided"
});
if (mandatoryAttrs.length > 1) { export const apiSanSchema = z
return false; .object({
} type: z.nativeEnum(CertSubjectAlternativeNameType),
allowed: z.array(z.string().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string().min(1, "Value cannot be empty")).optional()
})
.refine((data) => data.allowed || data.required || data.denied, {
message: "At least one allowed, required, or denied value must be provided"
});
if (mandatoryAttrs.length === 1 && attrs.length > 1) { export const apiTemplateSchema = z.object({
return false; name: z.string().trim().min(1, "Template name is required"),
} description: z.string().optional(),
} subject: z.array(apiSubjectSchema).optional(),
sans: z.array(apiSanSchema).optional(),
return true;
}, {
message: "Attribute validation failed: when a mandatory value exists, no other values are allowed for that attribute type"
}),
keyUsages: z keyUsages: z
.object({ .object({
requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional() required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
}) })
.optional(), .optional(),
extendedKeyUsages: z extendedKeyUsages: z
.object({ .object({
requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.optional(),
algorithms: z
.object({
signature: z.array(z.string()).optional(),
keyAlgorithm: z.array(z.string()).optional()
}) })
.optional(), .optional(),
subjectAlternativeNames: z.array(sanSchema).optional(),
validity: z validity: z
.object({ .object({
maxDuration: z max: z
.object({ .string()
value: z.number().min(1, "Duration must be at least 1"), .regex(/^\d+[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'")
unit: z.nativeEnum(CertDurationUnit)
})
.optional(),
minDuration: z
.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.nativeEnum(CertDurationUnit)
})
.optional() .optional()
}) })
.optional(),
signatureAlgorithm: z
.object({
allowedAlgorithms: z.array(z.string()).optional(),
defaultAlgorithm: z.string().optional()
})
.optional(),
keyAlgorithm: z
.object({
allowedKeyTypes: z.array(z.string()).optional(),
defaultKeyType: z.string().optional()
})
.optional() .optional()
}); });
export type TemplateFormData = z.infer<typeof templateSchema>; export type ApiTemplateFormData = z.infer<typeof apiTemplateSchema>;
@@ -2,7 +2,9 @@ import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
CertKeyUsageType, CertKeyUsageType,
formatExtendedKeyUsage, formatExtendedKeyUsage,
formatKeyUsage formatKeyUsage,
USAGE_STATES,
UsageState
} from "./certificate-constants"; } from "./certificate-constants";
export const formatUsageName = (usage: string): string => { export const formatUsageName = (usage: string): string => {
@@ -14,6 +16,7 @@ export const formatUsageName = (usage: string): string => {
return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType); return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType);
} }
} catch { } catch {
// Handle any errors in type checking
} }
return usage.replace(/_/g, " "); return usage.replace(/_/g, " ");
}; };
@@ -22,15 +25,15 @@ export const getUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType, usage: CertKeyUsageType | CertExtendedKeyUsageType,
requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[] optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[]
): "required" | "optional" | undefined => { ): UsageState => {
if (requiredUsages.includes(usage)) return "required"; if (requiredUsages.includes(usage)) return USAGE_STATES.REQUIRED;
if (optionalUsages.includes(usage)) return "optional"; if (optionalUsages.includes(usage)) return USAGE_STATES.OPTIONAL;
return undefined; return undefined;
}; };
export const toggleUsageState = ( export const toggleUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType, usage: CertKeyUsageType | CertExtendedKeyUsageType,
newState: "required" | "optional" | undefined, newState: UsageState,
currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[], currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void, toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void,
@@ -39,10 +42,10 @@ export const toggleUsageState = (
const isRequired = currentRequiredUsages.includes(usage); const isRequired = currentRequiredUsages.includes(usage);
const isOptional = currentOptionalUsages.includes(usage); const isOptional = currentOptionalUsages.includes(usage);
if (newState === "required") { if (newState === USAGE_STATES.REQUIRED) {
if (isOptional) toggleOptional(usage); if (isOptional) toggleOptional(usage);
if (!isRequired) toggleRequired(usage); if (!isRequired) toggleRequired(usage);
} else if (newState === "optional") { } else if (newState === USAGE_STATES.OPTIONAL) {
if (isRequired) toggleRequired(usage); if (isRequired) toggleRequired(usage);
if (!isOptional) toggleOptional(usage); if (!isOptional) toggleOptional(usage);
} else { } else {