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

View File

@@ -10,20 +10,19 @@ export async function up(knex: Knex): Promise<void> {
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project);
t.string("slug").notNullable();
t.string("name").notNullable();
t.string("description");
t.jsonb("attributes");
t.jsonb("subject");
t.jsonb("sans");
t.jsonb("keyUsages");
t.jsonb("extendedKeyUsages");
t.jsonb("subjectAlternativeNames");
t.jsonb("algorithms");
t.jsonb("validity");
t.jsonb("signatureAlgorithm");
t.jsonb("keyAlgorithm");
t.timestamps(true, true, true);
t.unique(["slug", "projectId"]);
t.unique(["name", "projectId"]);
});
await createOnUpdateTrigger(knex, TableName.CertificateTemplateV2);

View File

@@ -10,15 +10,14 @@ import { TImmutableDBKeys } from "./models";
export const CertificateTemplatesV2Schema = z.object({
id: z.string().uuid(),
projectId: z.string(),
slug: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
attributes: z.unknown().nullable().optional(),
subject: z.unknown().nullable().optional(),
sans: z.unknown().nullable().optional(),
keyUsages: z.unknown().nullable().optional(),
extendedKeyUsages: z.unknown().nullable().optional(),
subjectAlternativeNames: z.unknown().nullable().optional(),
algorithms: z.unknown().nullable().optional(),
validity: z.unknown().nullable().optional(),
signatureAlgorithm: z.unknown().nullable().optional(),
keyAlgorithm: z.unknown().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});

View File

@@ -11,8 +11,8 @@ import { registerAuthRoutes } from "./auth-router";
import { registerProjectBotRouter } from "./bot-router";
import { registerCaRouter } from "./certificate-authority-router";
import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateProfilesRouter } from "./certificate-profiles-router";
import { registerCertRouter } from "./certificate-router";
import { registerCertificateTemplateRouter } from "./certificate-template-router";
import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router";
import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router";

View File

@@ -1,110 +1,133 @@
import { z } from "zod";
import { CertificateTemplatesV2Schema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const templateV2SubjectSchema = z
.object({
type: attributeTypeSchema,
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Subject attribute must have at least one allowed, required, or denied value"
}
);
const templateV2KeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Key usages must have at least one allowed, required, or denied value"
}
);
const templateV2ExtendedKeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Extended key usages must have at least one allowed, required, or denied value"
}
);
const templateV2SanSchema = z
.object({
type: sanTypeSchema,
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "SAN must have at least one allowed, required, or denied value"
}
);
const templateV2ValiditySchema = z.object({
max: z
.string()
.regex(/^\d+[dhmy]$/, {
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
})
.optional()
});
const templateV2AlgorithmsSchema = z.object({
signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(),
keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional()
});
const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1),
name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"),
description: z.string().max(1000).optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional()
});
const updateCertificateTemplateV2Schema = z.object({
name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(),
description: z.string().max(1000).optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional()
});
export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => {
const templateV2AttributeSchema = z
.object({
type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.type === CertSubjectAttributeType.COMMON_NAME && data.value && data.value.length > 1) {
return false;
}
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
}
);
const templateV2KeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
});
const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
});
const templateV2SanSchema = z
.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Mandatory SANs can only have one value or no value (empty)"
}
);
const templateV2ValiditySchema = z.object({
maxDuration: z.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
}),
minDuration: z
.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
})
.optional()
});
const templateV2SignatureAlgorithmSchema = z.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
});
const templateV2KeyAlgorithmSchema = z.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
});
server.route({
method: "POST",
url: "/",
@@ -114,39 +137,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
body: z
.object({
projectId: z.string().min(1),
slug: slugSchema({ min: 1, max: 255 }),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
})
.refine(
(data) => {
const hasConstraints =
(data.attributes && data.attributes.length > 0) ||
(data.subjectAlternativeNames && data.subjectAlternativeNames.length > 0) ||
data.keyUsages ||
data.extendedKeyUsages ||
data.validity ||
data.signatureAlgorithm ||
data.keyAlgorithm;
return hasConstraints;
},
{
message:
"Certificate template must define at least one constraint (attributes, SANs, key usages, validity, or algorithms)"
}
),
body: createCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -169,7 +163,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug,
name: certificateTemplate.name,
projectId: certificateTemplate.projectId
}
}
@@ -196,7 +190,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplates: CertificateTemplatesV2Schema.array(),
certificateTemplates: certificateTemplateV2ResponseSchema.array(),
totalCount: z.number()
})
}
@@ -240,7 +234,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -261,7 +255,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.GET_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});
@@ -282,20 +276,10 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
params: z.object({
id: z.string().uuid()
}),
body: z.object({
slug: slugSchema({ min: 1, max: 255 }).optional(),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
}),
body: updateCertificateTemplateV2Schema,
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -317,7 +301,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});
@@ -340,7 +324,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesV2Schema
certificateTemplate: certificateTemplateV2ResponseSchema
})
}
},
@@ -361,7 +345,7 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
type: EventType.DELETE_CERTIFICATE_TEMPLATE,
metadata: {
certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.slug
name: certificateTemplate.name
}
}
});

View File

@@ -32,6 +32,17 @@ export enum CertIncludeType {
PROHIBIT = "prohibit"
}
export enum CertAttributeRule {
ALLOW = "allow",
DENY = "deny"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
@@ -39,7 +50,9 @@ export enum CertDurationUnit {
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
}
export const mapSANTypeToLegacy = (type: CertSubjectAlternativeNameType): string => {
@@ -184,3 +197,5 @@ export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType
export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType);
export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule);
export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect);

View File

@@ -50,43 +50,27 @@ export const buildCertificateSubjectFromTemplate = (
request: Record<string, unknown>,
templateAttributes?: Array<{
type: string;
include: "mandatory" | "optional" | "prohibit";
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>
): Record<string, string | undefined> => {
const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = {
common_name: "commonName"
common_name: "commonName",
organization: "organization",
country: "country"
};
if (!templateAttributes || templateAttributes.length === 0) {
throw new Error(
"Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints."
);
return subject;
}
const allowedAttributes = new Set(templateAttributes.map((attr) => attributeMap[attr.type]));
Object.keys(attributeMap).forEach((templateType) => {
const requestKey = attributeMap[templateType];
const value = request[requestKey];
if (value && !allowedAttributes.has(requestKey)) {
throw new Error(
`Certificate attribute '${requestKey}' is not allowed by the template. Template must define constraints for all requested attributes.`
);
}
});
templateAttributes.forEach((attr) => {
if (attr.include === "prohibit") {
return;
}
const requestKey = attributeMap[attr.type];
const value = request[requestKey];
if (value && typeof value === "string") {
if (value && typeof value === "string" && (attr.allowed || attr.required)) {
subject[attr.type] = value;
}
});
@@ -98,8 +82,9 @@ export const buildSubjectAlternativeNamesFromTemplate = (
request: { subjectAlternativeNames?: Array<{ type: string; value: string }> },
templateSans?: Array<{
type: string;
include: "mandatory" | "optional" | "prohibit";
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>
): string => {
if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) {
@@ -107,33 +92,13 @@ export const buildSubjectAlternativeNamesFromTemplate = (
}
if (!templateSans || templateSans.length === 0) {
if (request.subjectAlternativeNames.length > 0) {
throw new Error(
"Template must define allowed subject alternative names. Cannot issue certificate with SANs when template has no SAN constraints."
);
}
return "";
return request.subjectAlternativeNames.map((san) => san.value).join(",");
}
const templateSanTypes = new Set(templateSans.map((san) => san.type));
const prohibitedTypes = new Set(templateSans.filter((san) => san.include === "prohibit").map((san) => san.type));
request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type;
if (!templateSanTypes.has(sanType)) {
throw new Error(
`Subject Alternative Name type '${sanType}' is not allowed by the template. Template must define constraints for all requested SAN types.`
);
}
});
const allowedSans: string[] = [];
request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type;
if (!prohibitedTypes.has(sanType)) {
allowedSans.push(san.value);
}
allowedSans.push(san.value);
});
return allowedSans.join(",");

View File

@@ -68,7 +68,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
(tx || db).ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
(tx || db).ref("id").withSchema(TableName.CertificateTemplateV2).as("templateId"),
(tx || db).ref("projectId").withSchema(TableName.CertificateTemplateV2).as("templateProjectId"),
(tx || db).ref("slug").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("name").withSchema(TableName.CertificateTemplateV2).as("templateName"),
(tx || db).ref("description").withSchema(TableName.CertificateTemplateV2).as("templateDescription"),
(tx || db).ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"),
(tx || db)

View File

@@ -23,19 +23,12 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => {
const serialized = { ...data } as Record<string, unknown>;
const jsonFields = [
"attributes",
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
jsonFields.forEach((field) => {
const value = (data as Record<string, unknown>)[field];
if (value !== undefined) {
const value = serialized[field];
if (value !== undefined && typeof value !== "string") {
serialized[field] = JSON.stringify(value);
}
});
@@ -44,21 +37,15 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
};
const parseJsonFields = (raw: Record<string, unknown>): TCertificateTemplateV2 => {
const jsonFields = [
"attributes",
"keyUsages",
"extendedKeyUsages",
"subjectAlternativeNames",
"validity",
"signatureAlgorithm",
"keyAlgorithm"
];
const parsed = { ...raw };
const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"];
const parsed = { ...raw } as Record<string, unknown>;
jsonFields.forEach((field) => {
const value = raw[field];
if (value) {
if (value !== null && value !== undefined) {
parsed[field] = typeof value === "string" ? JSON.parse(value) : value;
} else {
parsed[field] = undefined;
}
});
@@ -143,7 +130,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -169,7 +156,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
if (search) {
query = query.where((builder) => {
void builder.whereILike("slug", `%${search}%`).orWhereILike("description", `%${search}%`);
void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`);
});
}
@@ -180,10 +167,10 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
}
};
const findBySlugAndProjectId = async (slug: string, projectId: string, tx?: Knex) => {
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
try {
const certificateTemplateV2 = await (tx || db)(TableName.CertificateTemplateV2)
.where({ slug, projectId })
.where({ name, projectId })
.first();
if (!certificateTemplateV2) {
@@ -192,7 +179,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
return parseJsonFields(certificateTemplateV2);
} catch (error) {
throw new DatabaseError({ error, name: "Find certificate template v2 by slug and project id" });
throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" });
}
};
@@ -238,7 +225,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
findById,
findByProjectId,
countByProjectId,
findBySlugAndProjectId,
findByNameAndProjectId,
isTemplateInUse,
getProfilesUsingTemplate
};

View File

@@ -1,166 +1,123 @@
import { z } from "zod";
import { slugSchema } from "@app/server/lib/schemas";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const includeTypeSchema = z.nativeEnum(CertIncludeType);
const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const durationUnitSchema = z.nativeEnum(CertDurationUnit);
export const templateV2AttributeSchema = z
const templateV2SubjectSchema = z
.object({
type: attributeTypeSchema,
include: includeTypeSchema,
value: z.array(z.string()).optional()
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.type === "common_name" && data.value && data.value.length > 1) {
return false;
}
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
message: "Subject attribute must have at least one allowed, required, or denied value"
}
);
export const templateV2KeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
});
const templateV2KeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Key usages must have at least one allowed, required, or denied value"
}
);
export const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
});
const templateV2ExtendedKeyUsagesSchema = z
.object({
allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
})
.refine(
(data) => {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Extended key usages must have at least one allowed, required, or denied value"
}
);
export const templateV2SanSchema = z
const templateV2SanSchema = z
.object({
type: sanTypeSchema,
include: includeTypeSchema,
value: z.array(z.string()).optional()
allowed: z.array(z.string()).optional(),
required: z.array(z.string()).optional(),
denied: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.include === "mandatory" && (!data.value || data.value.length > 1)) {
if (!data.allowed && !data.required && !data.denied) {
return false;
}
return true;
},
{
message: "Mandatory SANs can only have one value or no value (empty)"
message: "SAN must have at least one allowed, required, or denied value"
}
);
export const templateV2ValiditySchema = z.object({
maxDuration: z.object({
value: z.number().positive(),
unit: durationUnitSchema
}),
minDuration: z
.object({
value: z.number().positive(),
unit: durationUnitSchema
const templateV2ValiditySchema = z.object({
max: z
.string()
.regex(/^\d+[dhmy]$/, {
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
})
.optional()
});
export const templateV2SignatureAlgorithmSchema = z
.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
})
.refine((data) => data.allowedAlgorithms.includes(data.defaultAlgorithm), {
message: "Default signature algorithm must be included in the allowed algorithms list"
});
export const templateV2KeyAlgorithmSchema = z
.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
})
.refine((data) => data.allowedKeyTypes.includes(data.defaultKeyType), {
message: "Default key algorithm must be included in the allowed key types list"
});
export const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1),
slug: slugSchema({ min: 1, max: 255 }),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).min(1),
keyUsages: templateV2KeyUsagesSchema,
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
const templateV2AlgorithmsSchema = z.object({
signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(),
keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional()
});
export const updateCertificateTemplateV2Schema = z.object({
slug: slugSchema({ min: 1, max: 255 }).optional(),
description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(),
export const certificateTemplateV2ResponseSchema = z.object({
id: z.string().uuid(),
projectId: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(),
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
algorithms: templateV2AlgorithmsSchema.optional(),
validity: templateV2ValiditySchema.optional(),
signatureAlgorithm: templateV2SignatureAlgorithmSchema.optional(),
keyAlgorithm: templateV2KeyAlgorithmSchema.optional()
});
export const getCertificateTemplateV2ByIdSchema = z.object({
id: z.string().uuid()
});
export const getCertificateTemplateV2BySlugSchema = z.object({
projectId: z.string().min(1),
slug: slugSchema()
});
export const listCertificateTemplatesV2Schema = z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional()
});
export const deleteCertificateTemplateV2Schema = z.object({
id: z.string().uuid()
createdAt: z.date(),
updatedAt: z.date()
});
export const certificateRequestSchema = z.object({
commonName: z.string().optional(),
organization: z.string().optional(),
organizationName: z.string().optional(),
country: z.string().optional(),
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
subjectAlternativeNames: z

View File

@@ -12,7 +12,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { CertIncludeType, CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
import {
TCertificateRequest,
@@ -57,75 +57,86 @@ export const certificateTemplateV2ServiceFactory = ({
}
};
const convertToMilliseconds = (value: number, unit: "days" | "months" | "years"): number => {
switch (unit) {
case "days":
return value * 24 * 60 * 60 * 1000;
case "months":
return value * 30 * 24 * 60 * 60 * 1000;
case "years":
return value * 365 * 24 * 60 * 60 * 1000;
default:
throw new Error(`Unsupported duration unit: ${unit as string}`);
}
};
const validateSubjectAttributePolicy = (
subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
) => {
if (!subject || subject.length === 0) return;
const validateSubjectAttributePolicy = (attributes: Array<{ type: string; include: string; value?: string[] }>) => {
if (!attributes || attributes.length === 0) return;
const attributesByType = attributes.reduce(
(acc, attr) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push(attr);
return acc;
},
{} as Record<string, typeof attributes>
);
for (const [type, attrs] of Object.entries(attributesByType)) {
const mandatoryAttrs = attrs.filter((attr) => attr.include === CertIncludeType.MANDATORY);
if (mandatoryAttrs.length > 1) {
// Validate each subject attribute policy
for (const attr of subject) {
// Ensure at least one field is provided
if (!attr.allowed && !attr.required && !attr.denied) {
throw new ForbiddenRequestError({
message: `Multiple mandatory values found for subject attribute type '${type}'. Only one mandatory value is allowed per attribute type.`
message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value`
});
}
if (mandatoryAttrs.length === 1 && attrs.length > 1) {
throw new ForbiddenRequestError({
message: `When a mandatory value exists for subject attribute type '${type}', no other values (optional or forbidden) are allowed for that attribute type.`
});
// Check for duplicate values within arrays
const arrays = [
{ name: "allowed", values: attr.allowed },
{ name: "required", values: attr.required },
{ name: "denied", values: attr.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'`
});
}
}
}
}
};
const getRequestAttributeValue = (
request: TCertificateRequest,
attrType: CertSubjectAttributeType | string
): string | undefined => {
switch (attrType) {
case CertSubjectAttributeType.COMMON_NAME:
case "common_name":
return request.commonName;
default:
return undefined;
const validateSanPolicy = (
sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }>
) => {
if (!sans || sans.length === 0) return;
// Validate each SAN policy
for (const san of sans) {
if (!san.allowed && !san.required && !san.denied) {
throw new ForbiddenRequestError({
message: `SAN type '${san.type}' must have at least one allowed, required, or denied value`
});
}
const arrays = [
{ name: "allowed", values: san.allowed },
{ name: "required", values: san.required },
{ name: "denied", values: san.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} list for SAN type '${san.type}'`
});
}
}
}
}
};
const generateTemplateSlug = (baseSlug?: string): string => {
if (baseSlug) {
return slugify(baseSlug);
const generateTemplateSlug = (baseName?: string): string => {
if (baseName) {
return slugify(baseName);
}
return slugify(alphaNumericNanoId(12));
};
const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise<string> => {
const existingTemplate = await certificateTemplateV2DAL.findBySlugAndProjectId(desiredSlug, projectId);
const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId);
if (!existingTemplate || (templateId && existingTemplate.id === templateId)) {
return desiredSlug;
}
const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`;
const existingAlternative = await certificateTemplateV2DAL.findBySlugAndProjectId(alternativeSlug, projectId);
const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId);
if (!existingAlternative) {
return alternativeSlug;
}
@@ -139,8 +150,12 @@ export const certificateTemplateV2ServiceFactory = ({
};
const createWildcardRegex = (pattern: string): RegExp => {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
const regexPattern = escaped.replace(/\*/g, ".*");
const wildcardRegex = new RE2(/\*/g);
const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__");
const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g);
const escaped = withPlaceholder.replace(escapeRegex, "\\$&");
const placeholderRegex = new RE2(/__WILDCARD__/g);
const regexPattern = escaped.replace(placeholderRegex, ".*");
return new RE2(`^${regexPattern}$`);
};
@@ -166,6 +181,64 @@ export const certificateTemplateV2ServiceFactory = ({
return mapping[templateFormat] || templateFormat;
};
const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => {
if (!keyUsages) return;
if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) {
throw new ForbiddenRequestError({
message: "Key usages must have at least one allowed, required, or denied value"
});
}
const arrays = [
{ name: "allowed", values: keyUsages.allowed },
{ name: "required", values: keyUsages.required },
{ name: "denied", values: keyUsages.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} key usages list`
});
}
}
}
};
const validateExtendedKeyUsagePolicy = (extendedKeyUsages: {
allowed?: string[];
required?: string[];
denied?: string[];
}) => {
if (!extendedKeyUsages) return;
if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) {
throw new ForbiddenRequestError({
message: "Extended key usages must have at least one allowed, required, or denied value"
});
}
const arrays = [
{ name: "allowed", values: extendedKeyUsages.allowed },
{ name: "required", values: extendedKeyUsages.required },
{ name: "denied", values: extendedKeyUsages.denied }
];
for (const { name, values } of arrays) {
if (values && values.length > 0) {
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
throw new ForbiddenRequestError({
message: `Duplicate values found in ${name} extended key usages list`
});
}
}
}
};
const validateValueAgainstConstraints = (
value: string,
allowedValues: string[],
@@ -184,7 +257,7 @@ export const certificateTemplateV2ServiceFactory = ({
if (regex.test(value)) {
return { isValid: true };
}
} catch {
} catch (error) {
if (allowedValue === value) {
return { isValid: true };
}
@@ -213,89 +286,186 @@ export const certificateTemplateV2ServiceFactory = ({
const errors: string[] = [];
const warnings: string[] = [];
const templateAttributeTypes = new Set(template.attributes?.map((attr) => attr.type) || []);
// Validate subject attributes
const subjectPolicies = template.subject;
const requestAttributes = new Map<string, string>();
if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName);
if (request.organization || request.organizationName) {
requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization || request.organizationName!);
}
if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country);
const attributePoliciesByType = new Map<string, typeof template.attributes>();
template.attributes?.forEach((attrPolicy) => {
const existing = attributePoliciesByType.get(attrPolicy.type) || [];
attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]);
});
if (subjectPolicies && subjectPolicies.length > 0) {
// Validate each template subject attribute policy
for (const attrPolicy of subjectPolicies) {
const requestValue = requestAttributes.get(attrPolicy.type);
for (const [attrType, policies] of attributePoliciesByType) {
const requestValue = getRequestAttributeValue(request, attrType);
const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY);
const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT);
if (hasProhibit && requestValue) {
errors.push(`${attrType} is prohibited by template policy`);
// eslint-disable-next-line no-continue
continue;
}
if (hasMandatory && !requestValue) {
errors.push(`${attrType} is mandatory but not provided in request`);
// eslint-disable-next-line no-continue
continue;
}
if (requestValue) {
const policiesWithValues = policies.filter(
(p) =>
p.value &&
p.value.length > 0 &&
(p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL)
);
if (policiesWithValues.length > 0) {
const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []);
const validation = validateValueAgainstConstraints(requestValue, allAllowedValues, attrType);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
// Check denied values first
if (requestValue && attrPolicy.denied && attrPolicy.denied.length > 0) {
const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type);
if (validation.isValid) {
errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`);
// Skip further validation for this attribute if it's denied
} else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
// Check allowed values if present and not denied
const allowedValidation = validateValueAgainstConstraints(
requestValue,
attrPolicy.allowed,
attrPolicy.type
);
if (!allowedValidation.isValid && allowedValidation.error) {
errors.push(allowedValidation.error);
}
}
} else if (requestValue && attrPolicy.allowed && attrPolicy.allowed.length > 0) {
// Check allowed values if present and not denied
const allowedValidation = validateValueAgainstConstraints(requestValue, attrPolicy.allowed, attrPolicy.type);
if (!allowedValidation.isValid && allowedValidation.error) {
errors.push(allowedValidation.error);
}
}
}
}
const requestAttributeTypes: CertSubjectAttributeType[] = [];
if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME);
// Check for required subject attributes
for (const attrPolicy of subjectPolicies) {
if (attrPolicy.required && attrPolicy.required.length > 0) {
const requestValue = requestAttributes.get(attrPolicy.type);
if (!requestValue) {
errors.push(`Missing required ${attrPolicy.type} attribute`);
} else {
// Validate that the request value matches at least one required pattern
const hasMatchingRequired = attrPolicy.required.some((requiredValue) => {
const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type);
return validation.isValid;
});
if (!hasMatchingRequired) {
errors.push(
`${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}`
);
}
}
}
}
for (const requestAttrType of requestAttributeTypes) {
if (!templateAttributeTypes.has(requestAttrType)) {
errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`);
// Check if any request attributes are not covered by template policies
for (const [attrType] of requestAttributes) {
const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType);
if (!hasPolicy) {
errors.push(`${attrType} is not allowed by template policy (not defined in template)`);
}
}
} else if (requestAttributes.size > 0) {
// No subject policies defined but request has subject attributes - deny all
for (const [attrType] of requestAttributes) {
errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`);
}
}
if (template.keyUsages) {
if (template.keyUsages.requiredUsages && template.keyUsages.requiredUsages.all.length > 0) {
const missingRequired = template.keyUsages.requiredUsages.all.filter(
(usage) => !request.keyUsages?.includes(usage)
);
// Validate Subject Alternative Names
const sansPolicies = template.sans;
if (sansPolicies && sansPolicies.length > 0) {
const requestSansByType = new Map<string, string[]>();
// Group request SANs by type
if (request.subjectAlternativeNames) {
for (const san of request.subjectAlternativeNames) {
if (!requestSansByType.has(san.type)) {
requestSansByType.set(san.type, []);
}
requestSansByType.get(san.type)!.push(san.value);
}
}
// Validate each SAN policy
for (const sanPolicy of sansPolicies) {
const requestSans = requestSansByType.get(sanPolicy.type) || [];
// Check REQUIRED values - at least one SAN must match each required pattern
if (sanPolicy.required && sanPolicy.required.length > 0) {
for (const requiredValue of sanPolicy.required) {
const hasMatchingRequiredSan = requestSans.some((sanValue) => {
const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`);
return validation.isValid;
});
if (!hasMatchingRequiredSan) {
errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`);
}
}
}
// Check DENIED values - no SAN should match denied patterns
if (sanPolicy.denied && sanPolicy.denied.length > 0) {
for (const sanValue of requestSans) {
const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`);
if (validation.isValid) {
errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`);
}
}
}
// Check ALLOWED values - if present, all SANs must match at least one allowed pattern
if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) {
for (const sanValue of requestSans) {
const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
}
}
}
}
// Check if any request SANs are for types not covered by template policies
for (const [requestSanType] of requestSansByType) {
const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType);
if (!hasPolicy) {
errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`);
}
}
} else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) {
// No SAN policies defined but request has SANs - deny all
for (const san of request.subjectAlternativeNames) {
errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`);
}
}
// Validate key usages
const keyUsagePolicy = template.keyUsages;
if (keyUsagePolicy) {
// Check REQUIRED key usages - must have all required usages
if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) {
const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage));
if (missingRequired.length > 0) {
errors.push(`Missing required key usages: ${missingRequired.join(", ")}`);
}
}
if (request.keyUsages && (template.keyUsages.requiredUsages || template.keyUsages.optionalUsages)) {
const allAllowedUsages = [
...(template.keyUsages.requiredUsages?.all || []),
...(template.keyUsages.optionalUsages?.all || [])
];
// Check DENIED key usages - must not have any denied usages
if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) {
const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage));
if (deniedUsages.length > 0) {
errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`);
}
}
if (allAllowedUsages.length > 0) {
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`);
}
// Check ALLOWED key usages - if present, all usages must be in allowed list
if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) {
const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])];
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`);
}
}
} else if (request.keyUsages && request.keyUsages.length > 0) {
errors.push(`Key usages are not allowed by template policy (not defined in template)`);
}
if (template.extendedKeyUsages) {
if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) {
const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter(
// Validate extended key usages
const extendedKeyUsagePolicy = template.extendedKeyUsages;
if (extendedKeyUsagePolicy) {
// Check REQUIRED extended key usages - must have all required usages
if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) {
const missingRequired = extendedKeyUsagePolicy.required.filter(
(usage) => !request.extendedKeyUsages?.includes(usage)
);
if (missingRequired.length > 0) {
@@ -303,89 +473,46 @@ export const certificateTemplateV2ServiceFactory = ({
}
}
// Check DENIED extended key usages - must not have any denied usages
if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) {
const deniedUsages = request.extendedKeyUsages.filter((usage) =>
extendedKeyUsagePolicy?.denied?.includes(usage)
);
if (deniedUsages.length > 0) {
errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`);
}
}
// Check ALLOWED extended key usages - if present, all usages must be in allowed list
if (
request.extendedKeyUsages &&
(template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages)
extendedKeyUsagePolicy &&
extendedKeyUsagePolicy.allowed &&
extendedKeyUsagePolicy.allowed.length > 0
) {
const allAllowedUsages = [
...(template.extendedKeyUsages.requiredUsages?.all || []),
...(template.extendedKeyUsages.optionalUsages?.all || [])
const allAllowedExtendedUsages = [
...(extendedKeyUsagePolicy.required || []),
...(extendedKeyUsagePolicy.allowed || [])
];
if (allAllowedUsages.length > 0) {
const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`);
}
const invalidExtendedUsages = request.extendedKeyUsages.filter(
(usage) => !allAllowedExtendedUsages.includes(usage)
);
if (invalidExtendedUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`);
}
}
} else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) {
errors.push(`Extended key usages are not allowed by template policy (not defined in template)`);
}
const templateSanTypes = new Set(template.subjectAlternativeNames?.map((san) => san.type) || []);
const sanPoliciesByType = new Map<string, typeof template.subjectAlternativeNames>();
template.subjectAlternativeNames?.forEach((sanPolicy) => {
const existing = sanPoliciesByType.get(sanPolicy.type) || [];
sanPoliciesByType.set(sanPolicy.type, [...existing, sanPolicy]);
});
for (const [sanType, policies] of sanPoliciesByType) {
const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanType) || [];
const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY);
const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT);
if (hasProhibit && requestSans.length > 0) {
errors.push(`${sanType} SAN is prohibited by template policy`);
// eslint-disable-next-line no-continue
continue;
}
if (hasMandatory && requestSans.length === 0) {
errors.push(`${sanType} SAN is mandatory but not provided in request`);
// eslint-disable-next-line no-continue
continue;
}
if (requestSans.length > 0) {
const policiesWithValues = policies.filter(
(p) =>
p.value &&
p.value.length > 0 &&
(p.include === CertIncludeType.MANDATORY || p.include === CertIncludeType.OPTIONAL)
);
if (policiesWithValues.length > 0) {
const allAllowedValues = policiesWithValues.flatMap((p) => p.value || []);
requestSans.forEach((san) => {
const validation = validateValueAgainstConstraints(san.value, allAllowedValues, `${sanType} SAN`);
if (!validation.isValid && validation.error) {
errors.push(validation.error);
}
});
}
}
}
const requestSanTypes = new Set(request.subjectAlternativeNames?.map((san) => san.type) || []);
for (const requestSanType of requestSanTypes) {
if (!templateSanTypes.has(requestSanType)) {
errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`);
}
}
// Validate algorithms with new structure
if (request.signatureAlgorithm) {
if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) {
const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map(
mapTemplateSignatureAlgorithmToApi
);
if (template.algorithms?.signature && template.algorithms.signature.length > 0) {
const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi);
if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) {
errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
}
} else if (!template.signatureAlgorithm) {
} else if (!template.algorithms?.signature) {
errors.push(
`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)`
);
@@ -393,40 +520,19 @@ export const certificateTemplateV2ServiceFactory = ({
}
if (request.keyAlgorithm) {
if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) {
const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi);
if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) {
const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi);
if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) {
errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`);
}
} else if (!template.keyAlgorithm) {
} else if (!template.algorithms?.keyAlgorithm) {
errors.push(
`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)`
);
}
}
if (request.validity?.ttl && template.validity) {
const requestDuration = parseTTL(request.validity.ttl);
const maxDuration = convertToMilliseconds(
template.validity.maxDuration.value,
template.validity.maxDuration.unit
);
if (requestDuration > maxDuration) {
errors.push(`Requested validity period exceeds maximum allowed duration`);
}
if (template.validity.minDuration) {
const minDuration = convertToMilliseconds(
template.validity.minDuration.value,
template.validity.minDuration.unit
);
if (requestDuration < minDuration) {
errors.push(`Requested validity period is below minimum required duration`);
}
}
}
// Validate validity with new structure
if (request.validity?.ttl && (request.notBefore || request.notAfter)) {
errors.push(
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
@@ -437,30 +543,32 @@ export const certificateTemplateV2ServiceFactory = ({
errors.push("notBefore must be earlier than notAfter");
}
if ((request.notBefore || request.notAfter) && template.validity) {
// Validate TTL against template validity constraints
if (request.validity?.ttl && template.validity) {
const requestDurationMs = parseTTL(request.validity.ttl);
// Check maximum duration using max field
if (template.validity.max) {
const maxDurationMs = parseTTL(template.validity.max);
if (requestDurationMs > maxDurationMs) {
errors.push("Requested validity period exceeds maximum allowed duration");
}
}
}
// Validate explicit date range against max duration
if ((request.notBefore || request.notAfter) && template.validity?.max) {
const notBefore = request.notBefore || new Date();
const { notAfter } = request;
if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) {
const requestDuration = notAfter.getTime() - notBefore.getTime();
const maxDurationMs = parseTTL(template.validity.max);
const maxDuration = convertToMilliseconds(
template.validity.maxDuration.value,
template.validity.maxDuration.unit
);
if (requestDuration > maxDuration) {
errors.push(`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration`);
}
if (template.validity.minDuration) {
const minDuration = convertToMilliseconds(
template.validity.minDuration.value,
template.validity.minDuration.unit
if (requestDuration > maxDurationMs) {
errors.push(
`Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}`
);
if (requestDuration < minDuration) {
errors.push(`Requested validity period (notBefore to notAfter) is below minimum required duration`);
}
}
}
}
@@ -505,16 +613,33 @@ export const certificateTemplateV2ServiceFactory = ({
throw new Error("Template data is required");
}
if (data.attributes) {
validateSubjectAttributePolicy(data.attributes);
if (data.subject) {
validateSubjectAttributePolicy(data.subject);
}
const slug = data.slug || generateTemplateSlug();
if (data.sans) {
validateSanPolicy(data.sans);
}
if (data.keyUsages) {
validateKeyUsagePolicy(data.keyUsages);
}
if (data.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(data.extendedKeyUsages);
}
// Generate slug from name and ensure it's unique within project
if (!data.name) {
throw new ForbiddenRequestError({ message: "Template name is required" });
}
const slug = generateTemplateSlug(data.name);
const uniqueSlug = await ensureUniqueSlug(projectId, slug);
const template = await certificateTemplateV2DAL.create({
...data,
slug: uniqueSlug,
name: uniqueSlug,
projectId
});
@@ -555,14 +680,29 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates
);
if (data.attributes) {
validateSubjectAttributePolicy(data.attributes);
if (data.subject) {
validateSubjectAttributePolicy(data.subject);
}
if (data.sans) {
validateSanPolicy(data.sans);
}
if (data.keyUsages) {
validateKeyUsagePolicy(data.keyUsages);
}
if (data.extendedKeyUsages) {
validateExtendedKeyUsagePolicy(data.extendedKeyUsages);
}
const updateData = { ...data };
if (data.slug && typeof data.slug === "string" && data.slug !== existingTemplate.slug) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, data.slug, templateId);
updateData.slug = uniqueSlug;
if (data.name && typeof data.name === "string") {
const newSlug = generateTemplateSlug(data.name);
if (newSlug !== existingTemplate.name) {
const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId);
updateData.name = uniqueSlug;
}
}
const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData);
@@ -636,7 +776,7 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates
);
const template = await certificateTemplateV2DAL.findBySlugAndProjectId(slug, projectId);
const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
@@ -734,8 +874,8 @@ export const certificateTemplateV2ServiceFactory = ({
throw new ForbiddenRequestError({
message:
profilesUsingTemplate.length > 0
? `Cannot delete template '${template.slug}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.`
: `Cannot delete template '${template.slug}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.`
? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.`
: `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.`
});
}

View File

@@ -1,101 +1,71 @@
import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
export interface TTemplateV2Policy {
attributes: Array<{
subject?: Array<{
type: CertSubjectAttributeType;
include: CertIncludeType;
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages: {
requiredUsages?: { all: CertKeyUsageType[] };
optionalUsages?: { all: CertKeyUsageType[] };
};
extendedKeyUsages: {
requiredUsages?: { all: CertExtendedKeyUsageType[] };
optionalUsages?: { all: CertExtendedKeyUsageType[] };
};
subjectAlternativeNames: Array<{
sans?: Array<{
type: CertSubjectAlternativeNameType;
include: CertIncludeType;
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>;
validity: {
maxDuration: { value: number; unit: CertDurationUnit };
minDuration?: { value: number; unit: CertDurationUnit };
keyUsages?: {
allowed?: CertKeyUsageType[];
required?: CertKeyUsageType[];
denied?: CertKeyUsageType[];
};
signatureAlgorithm: {
allowedAlgorithms: string[];
defaultAlgorithm: string;
extendedKeyUsages?: {
allowed?: CertExtendedKeyUsageType[];
required?: CertExtendedKeyUsageType[];
denied?: CertExtendedKeyUsageType[];
};
keyAlgorithm: {
allowedKeyTypes: string[];
defaultKeyType: string;
algorithms?: {
signature?: string[];
keyAlgorithm?: string[];
};
validity?: {
max?: string;
};
}
export type TCertificateTemplateV2 = Omit<
TCertificateTemplatesV2,
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
> & {
attributes: TTemplateV2Policy["attributes"];
keyUsages: TTemplateV2Policy["keyUsages"];
extendedKeyUsages: TTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames: TTemplateV2Policy["subjectAlternativeNames"];
validity: TTemplateV2Policy["validity"];
signatureAlgorithm: TTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm: TTemplateV2Policy["keyAlgorithm"];
};
export type TCertificateTemplateV2Insert = Omit<
TCertificateTemplatesV2Insert,
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
> & {
attributes?: TTemplateV2Policy["attributes"];
export type TCertificateTemplateV2 = TCertificateTemplatesV2 & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames?: TTemplateV2Policy["subjectAlternativeNames"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
};
export type TCertificateTemplateV2Insert = TCertificateTemplatesV2Insert & {
subject?: TTemplateV2Policy["subject"];
sans?: TTemplateV2Policy["sans"];
keyUsages?: TTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"];
algorithms?: TTemplateV2Policy["algorithms"];
validity?: TTemplateV2Policy["validity"];
signatureAlgorithm?: TTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm?: TTemplateV2Policy["keyAlgorithm"];
};
export type TCertificateTemplateV2Update = Partial<
Pick<
TCertificateTemplateV2,
| "slug"
| "description"
| "attributes"
| "keyUsages"
| "extendedKeyUsages"
| "subjectAlternativeNames"
| "validity"
| "signatureAlgorithm"
| "keyAlgorithm"
"name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity"
>
>;
export interface TCertificateRequest {
commonName?: string;
organization?: string;
organizationName?: string;
organizationUnit?: string;
locality?: string;

View File

@@ -102,12 +102,12 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation:
const validateAlgorithmCompatibility = (
ca: TCertificateAuthorityWithAssociatedCa,
template: {
signatureAlgorithm?: {
allowedAlgorithms?: string[];
algorithms?: {
signature?: string[];
};
}
) => {
if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) {
if (!template.algorithms?.signature || template.algorithms.signature.length === 0) {
return;
}
@@ -116,7 +116,7 @@ const validateAlgorithmCompatibility = (
throw new BadRequestError({ message: "CA key algorithm not found" });
}
const compatibleAlgorithms = template.signatureAlgorithm.allowedAlgorithms.filter((sigAlg: string) => {
const compatibleAlgorithms = template.algorithms.signature.filter((sigAlg: string) => {
const parts = sigAlg.split("-");
const keyType = parts[parts.length - 1];
@@ -133,7 +133,7 @@ const validateAlgorithmCompatibility = (
if (compatibleAlgorithms.length === 0) {
throw new BadRequestError({
message: `Template signature algorithms (${template.signatureAlgorithm.allowedAlgorithms.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
message: `Template signature algorithms (${template.algorithms.signature.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
});
}
};
@@ -180,7 +180,10 @@ export const certificateV3ServiceFactory = ({
});
}
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const mappedCertificateRequest = mapEnumsForValidation({
...certificateRequest,
subjectAlternativeNames: certificateRequest.altNames
});
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
@@ -217,26 +220,25 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm =
certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
message: "Signature algorithm is required by template policy but not provided in request"
});
}
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes);
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject);
const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
{ subjectAlternativeNames: certificateRequest.altNames },
template.subjectAlternativeNames
template.sans
);
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
@@ -326,18 +328,18 @@ export const certificateV3ServiceFactory = ({
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
const effectiveSignatureAlgorithm = signatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
message: "Signature algorithm is required by template policy but not provided in request"
});
}

View File

@@ -1,5 +1,4 @@
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
export {
useCreateCa,
useCreateCertificate,
@@ -24,3 +23,4 @@ export {
useListCasByTypeAndProjectId,
useListExternalCasByProjectId
} from "./queries";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";

View File

@@ -123,78 +123,74 @@ export type TListCertificateTemplatesDTO = {
};
export type TCertificateTemplateV2Policy = {
attributes: Array<{
type: "common_name";
include: "mandatory" | "optional" | "prohibit";
value?: string[];
subject?: Array<{
type: "common_name" | "organization" | "country";
allowed?: string[];
required?: string[];
denied?: string[];
}>;
keyUsages: {
requiredUsages: { all: string[] };
optionalUsages: { all: string[] };
};
extendedKeyUsages: {
requiredUsages: { all: string[] };
optionalUsages: { all: string[] };
};
subjectAlternativeNames: Array<{
sans?: Array<{
type: "dns_name" | "ip_address" | "email" | "uri";
include: "mandatory" | "optional" | "prohibit";
value?: string[];
allowed?: string[];
required?: string[];
denied?: string[];
}>;
validity: {
maxDuration: { value: number; unit: "days" | "months" | "years" };
minDuration?: { value: number; unit: "days" | "months" | "years" };
keyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
signatureAlgorithm: {
allowedAlgorithms: string[];
defaultAlgorithm: string;
extendedKeyUsages?: {
allowed?: string[];
required?: string[];
denied?: string[];
};
keyAlgorithm: {
allowedKeyTypes: string[];
defaultKeyType: string;
algorithms?: {
signature?: string[];
keyAlgorithm?: string[];
};
validity?: {
max?: string;
};
};
export type TCertificateTemplateV2New = {
id: string;
projectId: string;
slug: string;
name: string;
description?: string;
attributes: any;
keyUsages: any;
extendedKeyUsages: any;
subjectAlternativeNames: any;
validity: any;
signatureAlgorithm: any;
keyAlgorithm: any;
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
createdAt: string;
updatedAt: string;
};
export type TCreateCertificateTemplateV2NewDTO = {
projectId: string;
slug: string;
name: string;
description?: string;
attributes: TCertificateTemplateV2Policy["attributes"];
keyUsages: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages: TCertificateTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames: TCertificateTemplateV2Policy["subjectAlternativeNames"];
validity: TCertificateTemplateV2Policy["validity"];
signatureAlgorithm: TCertificateTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm: TCertificateTemplateV2Policy["keyAlgorithm"];
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
};
export type TUpdateCertificateTemplateV2NewDTO = {
templateId: string;
slug?: string;
name?: string;
description?: string;
attributes?: TCertificateTemplateV2Policy["attributes"];
subject?: TCertificateTemplateV2Policy["subject"];
sans?: TCertificateTemplateV2Policy["sans"];
keyUsages?: TCertificateTemplateV2Policy["keyUsages"];
extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"];
subjectAlternativeNames?: TCertificateTemplateV2Policy["subjectAlternativeNames"];
algorithms?: TCertificateTemplateV2Policy["algorithms"];
validity?: TCertificateTemplateV2Policy["validity"];
signatureAlgorithm?: TCertificateTemplateV2Policy["signatureAlgorithm"];
keyAlgorithm?: TCertificateTemplateV2Policy["keyAlgorithm"];
};
export type TDeleteCertificateTemplateV2NewDTO = {

View File

@@ -53,10 +53,10 @@ export const PkiManagerLayout = () => {
animate={{ x: 0 }}
exit={{ x: -150 }}
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">
<div className="border-mineshaft-600 flex items-center gap-3 border-b px-4 py-3.5 text-lg text-white">
<nav className="items-between flex h-full flex-col overflow-y-auto dark:scheme-dark">
<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" />
PKI Manager
</div>
@@ -267,7 +267,7 @@ export const PkiManagerLayout = () => {
<Menu>
<Link to="/organization/projects">
<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={
<div className="w-6">
<FontAwesomeIcon className="mx-1 inline-block shrink-0" icon={faHome} />
@@ -281,13 +281,13 @@ export const PkiManagerLayout = () => {
</div>
</nav>
</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 />}
<Outlet />
</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" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
{` ${t("common.no-mobile")} `}

View File

@@ -1,9 +1,6 @@
/* eslint-disable react/no-array-index-key */
/* eslint-disable no-nested-ternary */
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
@@ -35,13 +32,13 @@ import {
KEY_USAGES_OPTIONS,
SIGNATURE_ALGORITHMS_OPTIONS
} from "@app/hooks/api/certificates/constants";
import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage
} from "@app/hooks/api/certificates/enums";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import {
mapTemplateKeyAlgorithmToApi,
mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
import { CertificateContent } from "./CertificateContent";
@@ -109,9 +106,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const [allowedKeyUsages, setAllowedKeyUsages] = useState<string[]>([]);
const [allowedExtendedKeyUsages, setAllowedExtendedKeyUsages] = useState<string[]>([]);
const [requiredKeyUsages, setRequiredKeyUsages] = useState<string[]>([]);
const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState<string[]>([]);
const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]);
const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]);
const { currentProject } = useProject();
const { data: cert } = useGetCert(
(popUp?.certificateIssuance?.data as { serialNumber: string })?.serialNumber || ""
);
@@ -133,7 +133,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
profileId: profileId ? profileId : "",
profileId: profileId || "",
subjectAttributes: [{ type: "common_name", value: "" }],
subjectAltNames: [],
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 selectedProfile = profilesData?.certificateProfiles?.find(
(p) => p.id === selectedProfileId
const selectedProfile = useMemo(
() => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId),
[profilesData?.certificateProfiles, selectedProfileId]
);
const { data: templateData } = useGetCertificateTemplateV2ById({
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(() => {
if (templateData && selectedProfile) {
if (templateData.signatureAlgorithm?.allowedAlgorithms && templateData.signatureAlgorithm.allowedAlgorithms.length > 0) {
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"
};
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 (templateData && selectedProfile && popUp?.certificateIssuance?.isOpen) {
if (templateData.algorithms?.signature && templateData.algorithms.signature.length > 0) {
setAllowedSignatureAlgorithms(templateData.algorithms.signature);
} else {
setAllowedSignatureAlgorithms([]);
}
if (
templateData.subjectAlternativeNames &&
Array.isArray(templateData.subjectAlternativeNames)
templateData.algorithms?.keyAlgorithm &&
templateData.algorithms.keyAlgorithm.length > 0
) {
const templateSans: Array<{ type: "dns" | "ip" | "email" | "uri"; value: string }> = [];
templateData.subjectAlternativeNames.forEach((sanPolicy) => {
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);
}
setAllowedKeyAlgorithms(templateData.algorithms.keyAlgorithm);
} else {
setAllowedKeyAlgorithms([]);
}
const resetKeyUsages = {
[CertKeyUsage.DIGITAL_SIGNATURE]: false,
[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;
}
});
if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max);
}
const currentExtendedKeyUsages = { ...resetExtendedKeyUsages };
if (templateData.extendedKeyUsages?.requiredUsages?.all) {
templateData.extendedKeyUsages.requiredUsages.all.forEach((usage: string) => {
const enumValue = (templateToEnumMap as any)[usage];
if (enumValue && enumValue in currentExtendedKeyUsages) {
(currentExtendedKeyUsages as any)[enumValue] = true;
}
});
const keyUsages: string[] = [];
if (templateData.keyUsages?.required) {
keyUsages.push(...templateData.keyUsages.required);
}
if (templateData.keyUsages?.allowed) {
keyUsages.push(...templateData.keyUsages.allowed);
}
setAllowedKeyUsages(keyUsages);
setValue("keyUsages", currentKeyUsages);
setValue("extendedKeyUsages", currentExtendedKeyUsages);
const extendedKeyUsages: string[] = [];
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(() => {
if (cert) {
@@ -451,83 +297,138 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const onFormSubmit = async ({
profileId,
subjectAttributes,
subjectAltNames,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages,
extendedKeyUsages
}: FormData) => {
try {
if (!currentProject?.slug) return;
const getAttributeValue = useCallback(
(subjectAttributes: typeof schema._type.subjectAttributes, type: string) => {
const foundAttr = subjectAttributes.find((attr) => attr.type === type);
return foundAttr?.value || "";
},
[]
);
const getAttributeValue = (type: string) => {
const foundAttr = subjectAttributes.find((attr) => attr.type === type);
return foundAttr?.value || "";
};
const formatSubjectAltNames = useCallback(
(subjectAltNames: typeof schema._type.subjectAltNames) => {
return subjectAltNames
.filter((san) => san.value.trim())
.map((san) => san.value.trim())
.join(", ");
},
[]
);
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
profileId,
projectSlug: currentProject.slug,
commonName: getAttributeValue("common_name"),
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)
});
const filterUsages = useCallback(<T extends Record<string, boolean>>(usages: T) => {
return Object.entries(usages)
.filter(([, value]) => value)
.map(([key]) => key);
}, []);
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({
serialNumber,
certificate,
certificateChain,
privateKey
});
if (!formProfileId) {
createNotification({
text: "Please select a certificate profile.",
type: "error"
});
return;
}
createNotification({
text: "Successfully created certificate",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create certificate",
type: "error"
});
const commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) {
createNotification({
text: "Common name is required.",
type: "error"
});
return;
}
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 "admin@example.com";
case "uri":
return "https://example.com";
default:
return "Enter value";
}
};
@@ -536,26 +437,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
isOpen={popUp?.certificateIssuance?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateIssuance", isOpen);
setCertificateDetails(null);
reset();
if (!isOpen) {
resetAllState();
}
}}
>
<ModalContent
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"
}
>
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
@@ -567,10 +454,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{cert && (
<div className="space-y-4">
<div>
<h4 className="text-mineshaft-300 text-sm font-medium">Certificate Details</h4>
<p className="text-mineshaft-400 text-sm">Serial Number: {cert.serialNumber}</p>
<p className="text-mineshaft-400 text-sm">Common Name: {cert.commonName}</p>
<p className="text-mineshaft-400 text-sm">Status: {cert.status}</p>
<h4 className="text-sm font-medium text-mineshaft-300">Certificate Details</h4>
<p className="text-sm text-mineshaft-400">Serial Number: {cert.serialNumber}</p>
<p className="text-sm text-mineshaft-400">Common Name: {cert.commonName}</p>
<p className="text-sm text-mineshaft-400">Status: {cert.status}</p>
</div>
</div>
)}
@@ -592,9 +479,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
className="text-center"
content={
<span>
Certificate profiles define the policies and enrollment methods for
certificate issuance. The selected profile will enforce validation
rules and determine the CA used for signing.
Certificate profiles define the policies and enrollment methods
for certificate issuance. The selected profile will enforce
validation rules and determine the CA used for signing.
</span>
}
>
@@ -629,7 +516,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{(selectedProfile || profileId) && (
<>
<Controller
control={control}
name="subjectAttributes"
@@ -642,7 +528,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
>
<div className="space-y-2">
{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
value={attr.type}
onValueChange={(newType) => {
@@ -710,7 +597,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
>
<div className="space-y-2">
{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
value={san.type}
onValueChange={(newType) => {
@@ -735,15 +626,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
newValue[index] = { ...san, value: e.target.value };
onChange(newValue);
}}
placeholder={
san.type === "dns"
? "example.com or *.example.com"
: san.type === "ip"
? "192.168.1.1"
: san.type === "email"
? "admin@example.com"
: "https://example.com"
}
placeholder={getSanPlaceholder(san.type)}
className="flex-1"
/>
<IconButton
@@ -807,13 +690,14 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Use template default"
placeholder={
availableSignatureAlgorithms.length > 0
? "Select signature algorithm"
: "No algorithms available"
}
position="popper"
>
{SIGNATURE_ALGORITHMS_OPTIONS.filter((algorithm) => {
if (allowedSignatureAlgorithms.length === 0) return true;
return allowedSignatureAlgorithms.includes(algorithm.value);
}).map((algorithm) => (
{availableSignatureAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
@@ -839,19 +723,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
placeholder="Use template default"
placeholder={
availableKeyAlgorithms.length > 0
? "Select key algorithm"
: "No algorithms available"
}
position="popper"
>
{certKeyAlgorithms
.filter((algorithm) => {
if (allowedKeyAlgorithms.length === 0) return true;
return allowedKeyAlgorithms.includes(algorithm.value);
})
.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
{availableKeyAlgorithms.map((algorithm) => (
<SelectItem key={algorithm.value} value={algorithm.value}>
{algorithm.label}
</SelectItem>
))}
</Select>
</FormControl>
)}
@@ -863,88 +746,80 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedKeyUsages.length === 0) return true;
const templateToEnumMap = {
digital_signature: CertKeyUsage.DIGITAL_SIGNATURE,
key_encipherment: CertKeyUsage.KEY_ENCIPHERMENT,
non_repudiation: CertKeyUsage.NON_REPUDIATION,
data_encipherment: CertKeyUsage.DATA_ENCIPHERMENT,
key_agreement: CertKeyUsage.KEY_AGREEMENT,
key_cert_sign: CertKeyUsage.KEY_CERT_SIGN,
crl_sign: CertKeyUsage.CRL_SIGN,
encipher_only: CertKeyUsage.ENCIPHER_ONLY,
decipher_only: CertKeyUsage.DECIPHER_ONLY
};
return allowedKeyUsages.some(
(allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value
);
}).map(({ label, value }) => (
<Controller
key={label}
control={control}
name={`keyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<FormLabel
id={`key-usage-${value}`}
className="text-mineshaft-300 cursor-pointer text-sm"
label={label}
/>
</div>
)}
/>
))}
</div>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredKeyUsages.map(({ label, value }) => {
const isRequired = requiredKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`keyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
id={`key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => {
if (allowedExtendedKeyUsages.length === 0) return true;
const templateToEnumMap = {
client_auth: CertExtendedKeyUsage.CLIENT_AUTH,
server_auth: CertExtendedKeyUsage.SERVER_AUTH,
code_signing: CertExtendedKeyUsage.CODE_SIGNING,
email_protection: CertExtendedKeyUsage.EMAIL_PROTECTION,
ocsp_signing: CertExtendedKeyUsage.OCSP_SIGNING,
time_stamping: CertExtendedKeyUsage.TIMESTAMPING,
timestamping: CertExtendedKeyUsage.TIMESTAMPING
};
return allowedExtendedKeyUsages.some(
(allowedUsage) => (templateToEnumMap as any)[allowedUsage] === value
);
}).map(({ label, value }) => (
<Controller
key={label}
control={control}
name={`extendedKeyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`ext-key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => field.onChange(checked)}
/>
<FormLabel
id={`ext-key-usage-${value}`}
className="text-mineshaft-300 cursor-pointer text-sm"
label={label}
/>
</div>
)}
/>
))}
</div>
<div className="grid grid-cols-2 gap-2 pl-2">
{filteredExtendedKeyUsages.map(({ label, value }) => {
const isRequired = requiredExtendedKeyUsages.includes(value);
return (
<Controller
key={label}
control={control}
name={`extendedKeyUsages.${value}` as any}
render={({ field }) => (
<div className="flex items-center space-x-3">
<Checkbox
id={`ext-key-usage-${value}`}
isChecked={field.value || false}
onCheckedChange={(checked) => {
if (!isRequired) {
field.onChange(checked);
}
}}
isDisabled={isRequired}
/>
<div className="flex items-center gap-2">
<FormLabel
id={`ext-key-usage-${value}`}
className={`text-sm ${isRequired ? "text-mineshaft-200" : "cursor-pointer text-mineshaft-300"}`}
label={label}
/>
{isRequired && <span className="text-xs">(Required)</span>}
</div>
</div>
)}
/>
);
})}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
@@ -964,7 +839,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("certificateIssuance", false)}
onClick={() => {
handlePopUpToggle("certificateIssuance", false);
}}
>
Cancel
</Button>

View File

@@ -87,16 +87,16 @@ export const PkiSubscriberSection = () => {
const subscriberName = subscriberStatusData?.subscriberName || "";
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">
<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">
<a
target="_blank"
rel="noopener noreferrer"
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{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}

View File

@@ -112,32 +112,30 @@ export const PkiTemplateListPage = () => {
/>
</div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
{
subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
{subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end">
<ProjectPermissionCan
I={ProjectPermissionPkiTemplateActions.Create}
a={ProjectPermissionSub.CertificateTemplates}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificateTemplate")}
isDisabled={!isAllowed}
className="ml-4"
>
Add Template
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
</div>
)
}
)}
<TableContainer>
<Table>
<THead>

View File

@@ -165,7 +165,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
if (isEdit) {
const updateData: any = {
profileId: profile.id,
name: data.slug,
slug: data.slug,
description: data.description
};
@@ -240,7 +240,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="your-profile-name" isDisabled={Boolean(isEdit)} />
<Input {...field} placeholder="your-profile-name" />
</FormControl>
)}
/>
@@ -319,7 +319,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
>
{certificateTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.slug}
{template.name}
</SelectItem>
))}
</Select>
@@ -376,17 +376,17 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<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
id="disableBootstrapCaValidation"
isChecked={value}
onCheckedChange={onChange}
/>
<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
</span>
<p className="text-bunker-300 text-xs">
<p className="text-xs text-bunker-300">
Skip CA certificate validation during EST bootstrap phase
</p>
</div>
@@ -433,7 +433,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
rows={6}
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
</p>
</div>

View File

@@ -1,7 +1,17 @@
/* 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 { createNotification } from "@app/components/notifications";
import {
Badge,
DropdownMenu,
@@ -18,12 +28,10 @@ import {
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
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";
interface Props {
@@ -37,9 +45,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp([
"certificateIssuance"
] as const);
const { popUp, handlePopUpToggle } = usePopUp(["certificateIssuance"] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false);
@@ -100,15 +106,15 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)}
</div>
</Td>
<Td className="text-center">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-center">
<Td className="text-start">{getEnrollmentTypeBadge(profile.enrollmentType)}</Td>
<Td className="text-start">
<span className="text-sm text-mineshaft-300">
{caData?.friendlyName || caData?.commonName || profile.caId}
</span>
</Td>
<Td>
<span className="text-sm text-mineshaft-300">
{templateData?.slug || profile.certificateTemplateId}
{templateData?.name || profile.certificateTemplateId}
</span>
</Td>
<Td>
@@ -176,19 +182,17 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
Edit Profile
</DropdownMenuItem>
)}
{
canIssueCertificate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)
}
{canIssueCertificate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)}
{canDeleteProfile && (
<DropdownMenuItem
onClick={(e) => {
@@ -202,7 +206,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)}
</DropdownMenuContent>
</DropdownMenu>
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} profileId={profile.id}/>
<CertificateIssuanceModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
profileId={profile.id}
/>
</Td>
</Tr>
);

View File

@@ -54,7 +54,7 @@ export const CertificateTemplatesV2Tab = () => {
setIsDeleteModalOpen(false);
setSelectedTemplate(null);
createNotification({
text: `Certificate template "${selectedTemplate.slug}" deleted successfully`,
text: `Certificate template "${selectedTemplate.name}" deleted successfully`,
type: "success"
});
} catch (error: any) {
@@ -103,12 +103,12 @@ export const CertificateTemplatesV2Tab = () => {
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete Certificate Template ${selectedTemplate.slug}?`}
title={`Delete Certificate Template ${selectedTemplate.name}?`}
onChange={(isOpen) => {
setIsDeleteModalOpen(isOpen);
if (!isOpen) setSelectedTemplate(null);
}}
deleteKey={selectedTemplate.slug}
deleteKey={selectedTemplate.name}
onDeleteApproved={handleDeleteConfirm}
/>
</>

View File

@@ -75,61 +75,64 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
</Td>
</Tr>
)}
{!isLoading && templates && templates.length > 0 && templates.map((template) => (
<Tr
key={template.id}
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
>
<Td>
<div className="flex items-center gap-2">
<div className="font-medium">{template.slug}</div>
{template.description && (
<Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</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} />
{!isLoading &&
templates &&
templates.length > 0 &&
templates.map((template) => (
<Tr
key={template.id}
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
>
<Td>
<div className="flex items-center gap-2">
<div className="font-medium">{template.name}</div>
{template.description && (
<Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</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 && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteTemplate(template);
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Template
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</div>
</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>
</div>
</DropdownMenuTrigger>
<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>
</Table>
</TableContainer>

View File

@@ -26,10 +26,16 @@ export enum CertExtendedKeyUsageType {
TIME_STAMPING = "time_stamping"
}
export enum CertIncludeType {
MANDATORY = "mandatory",
OPTIONAL = "optional",
PROHIBIT = "prohibit"
export enum CertAttributeRule {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertSanEffect {
ALLOW = "allow",
DENY = "deny",
REQUIRE = "require"
}
export enum CertDurationUnit {
@@ -39,7 +45,9 @@ export enum CertDurationUnit {
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
COMMON_NAME = "common_name",
ORGANIZATION = "organization",
COUNTRY = "country"
}
export const formatSANType = (type: CertSubjectAlternativeNameType): string => {
@@ -104,61 +112,117 @@ export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string
export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => {
switch (type) {
case CertSubjectAttributeType.COMMON_NAME:
return "Common Name";
return "Common Name (CN)";
case CertSubjectAttributeType.ORGANIZATION:
return "Organization";
case CertSubjectAttributeType.COUNTRY:
return "Country";
default:
return type;
}
};
export const formatIncludeType = (include: CertIncludeType): string => {
switch (include) {
case CertIncludeType.MANDATORY:
return "Mandatory";
case CertIncludeType.OPTIONAL:
return "Optional";
case CertIncludeType.PROHIBIT:
return "Prohibit";
export const formatAttributeRule = (rule: CertAttributeRule): string => {
switch (rule) {
case CertAttributeRule.ALLOW:
return "Allow";
case CertAttributeRule.DENY:
return "Deny";
case CertAttributeRule.REQUIRE:
return "Require";
default:
return include;
return rule;
}
};
export const mapLegacySANTypeToStandard = (type: string): CertSubjectAlternativeNameType => {
switch (type) {
case "dns":
case "dns_name":
return CertSubjectAlternativeNameType.DNS_NAME;
case "ip":
case "ip_address":
return CertSubjectAlternativeNameType.IP_ADDRESS;
case "email":
return CertSubjectAlternativeNameType.EMAIL;
case "uri":
case "url":
return CertSubjectAlternativeNameType.URI;
export const formatSanEffect = (effect: CertSanEffect): string => {
switch (effect) {
case CertSanEffect.ALLOW:
return "Allow";
case CertSanEffect.DENY:
return "Deny";
case CertSanEffect.REQUIRE:
return "Require";
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 KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType);
export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit);
export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType);
export const 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;
};

View File

@@ -1,152 +1,159 @@
import { Checkbox } from "@app/components/v2";
import React from "react";
import { Select, SelectItem } from "@app/components/v2";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
EXTENDED_KEY_USAGE_OPTIONS,
formatExtendedKeyUsage,
formatKeyUsage,
EXTENDED_KEY_USAGE_OPTIONS,
KEY_USAGE_OPTIONS
} from "./certificate-constants";
type UsageState = "mandatory" | "optional" | undefined;
type KeyUsagePolicy = "allow" | "require" | "deny" | "none";
type ThreeStateCheckboxProps = {
value: UsageState;
onChange: (newValue: UsageState) => void;
label: string;
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);
}
interface KeyUsagesSectionProps {
watchedKeyUsages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
};
const getCheckboxState = () => {
if (value) return true;
return false;
watchedExtendedKeyUsages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
};
onKeyUsagesChange: (usages: {
requiredUsages: CertKeyUsageType[];
optionalUsages: CertKeyUsageType[];
}) => void;
onExtendedKeyUsagesChange: (usages: {
requiredUsages: CertExtendedKeyUsageType[];
optionalUsages: CertExtendedKeyUsageType[];
}) => void;
}
const getIndeterminateState = () => {
return value === "optional";
};
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: [] },
export const KeyUsagesSection: React.FC<KeyUsagesSectionProps> = ({
watchedKeyUsages,
watchedExtendedKeyUsages,
onKeyUsagesChange,
onExtendedKeyUsagesChange
}: KeyUsagesSectionProps) => {
const getUsageState = (usage: string, data: { requiredUsages?: string[]; optionalUsages?: string[] }): UsageState => {
if (data.requiredUsages?.includes(usage)) return "mandatory";
if (data.optionalUsages?.includes(usage)) return "optional";
return undefined;
}) => {
const getKeyUsagePolicy = (usage: CertKeyUsageType): KeyUsagePolicy => {
if (watchedKeyUsages.requiredUsages.includes(usage)) return "require";
if (watchedKeyUsages.optionalUsages.includes(usage)) return "allow";
return "deny";
};
const handleKeyUsageChange = (usage: CertKeyUsageType, newState: UsageState) => {
const currentRequired = watchedKeyUsages.requiredUsages || [];
const currentOptional = watchedKeyUsages.optionalUsages || [];
const getExtendedKeyUsagePolicy = (usage: CertExtendedKeyUsageType): KeyUsagePolicy => {
if (watchedExtendedKeyUsages.requiredUsages.includes(usage)) return "require";
if (watchedExtendedKeyUsages.optionalUsages.includes(usage)) return "allow";
return "deny";
};
let newRequired = currentRequired.filter(u => u !== usage);
let newOptional = currentOptional.filter(u => u !== usage);
const handleKeyUsagePolicyChange = (usage: CertKeyUsageType, policy: KeyUsagePolicy) => {
const newRequired = watchedKeyUsages.requiredUsages.filter((u) => u !== usage);
const newOptional = watchedKeyUsages.optionalUsages.filter((u) => u !== usage);
if (newState === "mandatory") {
newRequired = [...newRequired, usage];
} else if (newState === "optional") {
newOptional = [...newOptional, usage];
if (policy === "require") {
newRequired.push(usage);
} else if (policy === "allow") {
newOptional.push(usage);
}
onKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional });
onKeyUsagesChange({
requiredUsages: newRequired,
optionalUsages: newOptional
});
};
const handleExtendedKeyUsageChange = (usage: CertExtendedKeyUsageType, newState: UsageState) => {
const currentRequired = watchedExtendedKeyUsages.requiredUsages || [];
const currentOptional = watchedExtendedKeyUsages.optionalUsages || [];
const handleExtendedKeyUsagePolicyChange = (
usage: CertExtendedKeyUsageType,
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);
let newOptional = currentOptional.filter(u => u !== usage);
if (newState === "mandatory") {
newRequired = [...newRequired, usage];
} else if (newState === "optional") {
newOptional = [...newOptional, usage];
if (policy === "require") {
newRequired.push(usage);
} else if (policy === "allow") {
newOptional.push(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 (
<div className="space-y-6">
<div className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Key Usages</h3>
<div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox
key={usage}
id={`key-usage-${usage}`}
label={formatKeyUsage(usage)}
value={getUsageState(usage, watchedKeyUsages)}
onChange={(newState) => handleKeyUsageChange(usage, newState)}
/>
))}
<div className="space-y-8">
{/* Key Usages */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-bunker-200">Key Usages</h4>
<div className="grid grid-cols-2 gap-4">
{KEY_USAGE_OPTIONS.map((usage) => {
const policy = getKeyUsagePolicy(usage);
return (
<div key={usage} className="flex items-center justify-between">
<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 className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Extended Key Usages</h3>
<div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGE_OPTIONS.map((usage) => (
<ThreeStateCheckbox
key={usage}
id={`ext-key-usage-${usage}`}
label={formatExtendedKeyUsage(usage)}
value={getUsageState(usage, watchedExtendedKeyUsages)}
onChange={(newState) => handleExtendedKeyUsageChange(usage, newState)}
/>
))}
{/* Extended Key Usages */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-bunker-200">Extended Key Usages</h4>
<div className="grid grid-cols-2 gap-4">
{EXTENDED_KEY_USAGE_OPTIONS.map((usage) => {
const policy = getExtendedKeyUsagePolicy(usage);
return (
<div key={usage} className="flex items-center justify-between">
<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>

View File

@@ -3,93 +3,121 @@ import { z } from "zod";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
CertSubjectAttributeType,
SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS,
SAN_INCLUDE_OPTIONS
} from "./certificate-constants";
export const attributeSchema = z.object({
export const uiAttributeSchema = z.object({
type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string().min(1, "Value cannot be empty")).optional()
include: z.enum(SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS),
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),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string().min(1, "Value cannot be empty")).optional()
include: z.enum(SAN_INCLUDE_OPTIONS),
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({
slug: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
attributes: z.array(attributeSchema).optional().refine((attributes) => {
if (!attributes) return true;
attributes: z.array(uiAttributeSchema).optional(),
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) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push(attr);
return acc;
}, {} as Record<string, typeof attributes>);
export type TemplateFormData = z.infer<typeof templateSchema>;
for (const [, attrs] of Object.entries(attributesByType)) {
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory');
export const apiSubjectSchema = z
.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) {
return false;
}
export const apiSanSchema = z
.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) {
return false;
}
}
return true;
}, {
message: "Attribute validation failed: when a mandatory value exists, no other values are allowed for that attribute type"
}),
export const apiTemplateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(),
subject: z.array(apiSubjectSchema).optional(),
sans: z.array(apiSanSchema).optional(),
keyUsages: z
.object({
requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional()
allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
required: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
denied: z.array(z.nativeEnum(CertKeyUsageType)).optional()
})
.optional(),
extendedKeyUsages: z
.object({
requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
allowed: 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(),
subjectAlternativeNames: z.array(sanSchema).optional(),
validity: z
.object({
maxDuration: z
.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.nativeEnum(CertDurationUnit)
})
.optional(),
minDuration: z
.object({
value: z.number().min(1, "Duration must be at least 1"),
unit: z.nativeEnum(CertDurationUnit)
})
max: z
.string()
.regex(/^\d+[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'")
.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()
});
export type TemplateFormData = z.infer<typeof templateSchema>;
export type ApiTemplateFormData = z.infer<typeof apiTemplateSchema>;

View File

@@ -2,7 +2,9 @@ import {
CertExtendedKeyUsageType,
CertKeyUsageType,
formatExtendedKeyUsage,
formatKeyUsage
formatKeyUsage,
USAGE_STATES,
UsageState
} from "./certificate-constants";
export const formatUsageName = (usage: string): string => {
@@ -14,6 +16,7 @@ export const formatUsageName = (usage: string): string => {
return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType);
}
} catch {
// Handle any errors in type checking
}
return usage.replace(/_/g, " ");
};
@@ -22,15 +25,15 @@ export const getUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType,
requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[]
): "required" | "optional" | undefined => {
if (requiredUsages.includes(usage)) return "required";
if (optionalUsages.includes(usage)) return "optional";
): UsageState => {
if (requiredUsages.includes(usage)) return USAGE_STATES.REQUIRED;
if (optionalUsages.includes(usage)) return USAGE_STATES.OPTIONAL;
return undefined;
};
export const toggleUsageState = (
usage: CertKeyUsageType | CertExtendedKeyUsageType,
newState: "required" | "optional" | undefined,
newState: UsageState,
currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void,
@@ -39,10 +42,10 @@ export const toggleUsageState = (
const isRequired = currentRequiredUsages.includes(usage);
const isOptional = currentOptionalUsages.includes(usage);
if (newState === "required") {
if (newState === USAGE_STATES.REQUIRED) {
if (isOptional) toggleOptional(usage);
if (!isRequired) toggleRequired(usage);
} else if (newState === "optional") {
} else if (newState === USAGE_STATES.OPTIONAL) {
if (isRequired) toggleRequired(usage);
if (!isOptional) toggleOptional(usage);
} else {