Addressed PR comments

This commit is contained in:
Carlos Monastyrski
2025-10-14 03:18:35 -03:00
parent 073be13906
commit 42800fdfe5
82 changed files with 4223 additions and 1956 deletions
@@ -8,7 +8,7 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TableName.CertificateTemplateV2, (t) => { await knex.schema.createTable(TableName.CertificateTemplateV2, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable(); t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.foreign("projectId").references("id").inTable(TableName.Project);
t.string("slug").notNullable(); t.string("slug").notNullable();
t.string("description"); t.string("description");
@@ -60,7 +60,7 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TableName.CertificateProfile, (t) => { await knex.schema.createTable(TableName.CertificateProfile, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable(); t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.foreign("projectId").references("id").inTable(TableName.Project);
t.uuid("caId").notNullable(); t.uuid("caId").notNullable();
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); t.foreign("caId").references("id").inTable(TableName.CertificateAuthority);
@@ -2569,19 +2569,41 @@ interface GetCertificateTemplateEstConfig {
interface CreateCertificateTemplate { interface CreateCertificateTemplate {
type: EventType.CREATE_CERTIFICATE_TEMPLATE; type: EventType.CREATE_CERTIFICATE_TEMPLATE;
metadata: { metadata:
certificateTemplateId: string; | {
name: string; certificateTemplateId: string;
projectId: string; name: string;
}; projectId: string;
}
| {
certificateTemplateId: string;
caId: string;
pkiCollectionId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
projectId: string;
};
} }
interface UpdateCertificateTemplate { interface UpdateCertificateTemplate {
type: EventType.UPDATE_CERTIFICATE_TEMPLATE; type: EventType.UPDATE_CERTIFICATE_TEMPLATE;
metadata: { metadata:
certificateTemplateId: string; | {
name: string; certificateTemplateId: string;
}; name: string;
}
| {
certificateTemplateId: string;
caId: string;
pkiCollectionId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
projectId: string;
};
} }
interface DeleteCertificateTemplate { interface DeleteCertificateTemplate {
@@ -2629,6 +2651,7 @@ interface DeleteCertificateProfile {
type: EventType.DELETE_CERTIFICATE_PROFILE; type: EventType.DELETE_CERTIFICATE_PROFILE;
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
name: string;
}; };
} }
@@ -2636,6 +2659,7 @@ interface GetCertificateProfile {
type: EventType.GET_CERTIFICATE_PROFILE; type: EventType.GET_CERTIFICATE_PROFILE;
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
name: string;
}; };
} }
@@ -2652,6 +2676,7 @@ interface IssueCertificateFromProfile {
certificateProfileId: string; certificateProfileId: string;
certificateId: string; certificateId: string;
commonName: string; commonName: string;
profileName: string;
}; };
} }
@@ -2660,6 +2685,8 @@ interface SignCertificateFromProfile {
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
certificateId: string; certificateId: string;
profileName: string;
commonName: string;
}; };
} }
@@ -2668,7 +2695,7 @@ interface OrderCertificateFromProfile {
metadata: { metadata: {
certificateProfileId: string; certificateProfileId: string;
orderId: string; orderId: string;
subjectAlternativeNames: string[]; profileName: string;
}; };
} }
@@ -1119,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource." "When specified, only matching conditions will be allowed to access given resource."
).optional() ).optional()
}), }),
z.object({
subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe(
"Describe what action an entity can take."
)
}),
...GeneralPermissionSchema ...GeneralPermissionSchema
]); ]);
@@ -1,3 +1,4 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { CertificateProfilesSchema } from "@app/db/schemas"; import { CertificateProfilesSchema } from "@app/db/schemas";
@@ -6,12 +7,7 @@ import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
createCertificateProfileSchema,
deleteCertificateProfileSchema,
listCertificateProfilesSchema,
updateCertificateProfileSchema
} from "@app/services/certificate-profile/certificate-profile-schemas";
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -23,7 +19,57 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
body: createCertificateProfileSchema, body: z
.object({
projectId: z.string().min(1),
caId: z.string().uuid(),
certificateTemplateId: z.string().uuid(),
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1),
encryptedCaChain: z.string()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfig) {
return false;
}
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) {
return false;
}
if (data.estConfig) {
return false;
}
}
return true;
},
{
message:
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
}
),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: CertificateProfilesSchema certificateProfile: CertificateProfilesSchema
@@ -68,7 +114,13 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
querystring: listCertificateProfilesSchema.extend({ querystring: z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional(),
includeMetrics: z.coerce.boolean().optional().default(false), includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7) expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}), }),
@@ -209,7 +261,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
event: { event: {
type: EventType.GET_CERTIFICATE_PROFILE, type: EventType.GET_CERTIFICATE_PROFILE,
metadata: { metadata: {
certificateProfileId: certificateProfile.id certificateProfileId: certificateProfile.id,
name: certificateProfile.slug
} }
} }
}); });
@@ -266,7 +319,48 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
params: z.object({ params: z.object({
id: z.string().min(1) id: z.string().min(1)
}), }),
body: updateCertificateProfileSchema, body: z
.object({
slug: z
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
.optional(),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
passphrase: z.string().min(1),
encryptedCaChain: z.string()
})
.optional(),
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
})
.optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (data.estConfig) {
return false;
}
}
return true;
},
{
message: "Cannot have EST config with API enrollment type or API config with EST enrollment type."
}
),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: CertificateProfilesSchema certificateProfile: CertificateProfilesSchema
@@ -309,7 +403,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles], tags: [ApiDocsTags.PkiCertificateProfiles],
params: deleteCertificateProfileSchema, params: z.object({
id: z.string().uuid()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: CertificateProfilesSchema certificateProfile: CertificateProfilesSchema
@@ -332,7 +428,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
event: { event: {
type: EventType.DELETE_CERTIFICATE_PROFILE, type: EventType.DELETE_CERTIFICATE_PROFILE,
metadata: { metadata: {
certificateProfileId: certificateProfile.id certificateProfileId: certificateProfile.id,
name: certificateProfile.slug
} }
} }
}); });
@@ -117,7 +117,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.CREATE_CERTIFICATE_TEMPLATE, type: EventType.CREATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
caId: certificateTemplate.caId,
pkiCollectionId: certificateTemplate.pkiCollectionId as string,
name: certificateTemplate.name, name: certificateTemplate.name,
commonName: certificateTemplate.commonName,
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl,
projectId: certificateTemplate.projectId projectId: certificateTemplate.projectId
} }
} }
@@ -181,7 +186,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
type: EventType.UPDATE_CERTIFICATE_TEMPLATE, type: EventType.UPDATE_CERTIFICATE_TEMPLATE,
metadata: { metadata: {
certificateTemplateId: certificateTemplate.id, certificateTemplateId: certificateTemplate.id,
name: certificateTemplate.name name: certificateTemplate.name,
caId: certificateTemplate.caId,
pkiCollectionId: certificateTemplate.pkiCollectionId as string,
commonName: certificateTemplate.commonName,
subjectAlternativeName: certificateTemplate.subjectAlternativeName,
ttl: certificateTemplate.ttl
} }
} }
}); });
@@ -4,18 +4,107 @@ import { CertificateTemplatesV2Schema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs"; import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { import {
certificateRequestSchema, CertDurationUnit,
createCertificateTemplateV2Schema, CertExtendedKeyUsageType,
deleteCertificateTemplateV2Schema, CertIncludeType,
getCertificateTemplateV2ByIdSchema, CertKeyUsageType,
listCertificateTemplatesV2Schema, CertSubjectAlternativeNameType,
updateCertificateTemplateV2Schema CertSubjectAttributeType
} from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; } from "@app/services/certificate-common/certificate-constants";
export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => {
const templateV2AttributeSchema = z
.object({
type: z.nativeEnum(CertSubjectAttributeType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.type === CertSubjectAttributeType.COMMON_NAME && data.value && data.value.length > 1) {
return false;
}
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
}
);
const templateV2KeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
});
const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional(),
optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
});
const templateV2SanSchema = z
.object({
type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional()
})
.refine(
(data) => {
if (data.include === CertIncludeType.MANDATORY && (!data.value || data.value.length > 1)) {
return false;
}
return true;
},
{
message: "Mandatory SANs can only have one value or no value (empty)"
}
);
const templateV2ValiditySchema = z.object({
maxDuration: z.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
}),
minDuration: z
.object({
value: z.number().positive(),
unit: z.nativeEnum(CertDurationUnit)
})
.optional()
});
const templateV2SignatureAlgorithmSchema = z.object({
allowedAlgorithms: z.array(z.string()).min(1),
defaultAlgorithm: z.string()
});
const templateV2KeyAlgorithmSchema = z.object({
allowedKeyTypes: z.array(z.string()).min(1),
defaultKeyType: z.string()
});
server.route({ server.route({
method: "POST", method: "POST",
url: "/", url: "/",
@@ -25,7 +114,36 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
body: createCertificateTemplateV2Schema, 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)"
}
),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: CertificateTemplatesV2Schema
@@ -70,7 +188,12 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
querystring: listCertificateTemplatesV2Schema, querystring: z.object({
projectId: z.string().min(1),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplates: CertificateTemplatesV2Schema.array(), certificateTemplates: CertificateTemplatesV2Schema.array(),
@@ -112,7 +235,9 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
params: getCertificateTemplateV2ByIdSchema, params: z.object({
id: z.string().uuid()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: CertificateTemplatesV2Schema
@@ -154,8 +279,20 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
params: getCertificateTemplateV2ByIdSchema, params: z.object({
body: updateCertificateTemplateV2Schema, 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()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: CertificateTemplatesV2Schema
@@ -198,7 +335,9 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates], tags: [ApiDocsTags.PkiCertificateTemplates],
params: deleteCertificateTemplateV2Schema, params: z.object({
id: z.string().uuid()
}),
response: { response: {
200: z.object({ 200: z.object({
certificateTemplate: CertificateTemplatesV2Schema certificateTemplate: CertificateTemplatesV2Schema
@@ -230,38 +369,4 @@ export const registerCertificateTemplatesV2Router = async (server: FastifyZodPro
return { certificateTemplate }; return { certificateTemplate };
} }
}); });
server.route({
method: "POST",
url: "/:id/validate",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: getCertificateTemplateV2ByIdSchema,
body: z.object({
request: certificateRequestSchema
}),
response: {
200: z.object({
valid: z.boolean(),
errors: z.array(z.string()).optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const result = await server.services.certificateTemplateV2.validateCertificateRequest(
req.params.id,
req.body.request
);
return {
valid: result.isValid,
errors: result.errors.length > 0 ? result.errors : undefined
};
}
});
}; };
@@ -6,12 +6,22 @@ import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; import {
ACMESANType,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
import { import {
validateAltNamesField, validateAltNamesField,
validateAndMapAltNameType, validateAndMapAltNameType,
validateCaDateField validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators"; } from "@app/services/certificate-authority/certificate-authority-validators";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType
} from "@app/services/certificate-common/certificate-constants";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
@@ -25,18 +35,43 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
body: z.object({ body: z
profileId: z.string().uuid(), .object({
commonName: validateTemplateRegexField, profileId: z.string().uuid(),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), commonName: validateTemplateRegexField,
keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(), extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notAfter: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
altNames: validateAltNamesField.optional(), notAfter: validateCaDateField.optional(),
signatureAlgorithm: z.string().optional(), subjectAltNames: validateAltNamesField.optional(),
keyAlgorithm: z.string().optional() signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
}), keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
})
.refine(
(data) => {
const hasDateFields = data.notBefore || data.notAfter;
const hasTtl = data.ttl;
return !(hasDateFields && hasTtl);
},
{
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
}
)
.refine(
(data) => {
if (data.notBefore && data.notAfter) {
const notBefore = new Date(data.notBefore);
const notAfter = new Date(data.notAfter);
return notBefore < notAfter;
}
return true;
},
{
message: "notBefore must be earlier than notAfter"
}
),
response: { response: {
200: z.object({ 200: z.object({
certificate: z.string().trim(), certificate: z.string().trim(),
@@ -54,8 +89,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
commonName: req.body.commonName, commonName: req.body.commonName,
keyUsages: req.body.keyUsages, keyUsages: req.body.keyUsages,
extendedKeyUsages: req.body.extendedKeyUsages, extendedKeyUsages: req.body.extendedKeyUsages,
subjectAlternativeNames: req.body.altNames altNames: req.body.subjectAltNames
? req.body.altNames ? req.body.subjectAltNames
.split(", ") .split(", ")
.map((name) => name.trim()) .map((name) => name.trim())
.map((name) => { .map((name) => {
@@ -68,7 +103,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
url: "uri" url: "uri"
} as const; } as const;
return { return {
type: typeMapping[mappedType.type] as "dns_name" | "ip_address" | "email" | "uri", type: typeMapping[mappedType.type] as CertSubjectAlternativeNameType,
value: mappedType.value value: mappedType.value
}; };
}) })
@@ -94,23 +129,16 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateRequest: mappedCertificateRequest certificateRequest: mappedCertificateRequest
}); });
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: profile.projectId, projectId: data.projectId,
event: { event: {
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
commonName: req.body.commonName || "" commonName: req.body.commonName || "",
profileName: data.profileName
} }
} }
}); });
@@ -128,13 +156,38 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
body: z.object({ body: z
profileId: z.string().uuid(), .object({
csr: z.string().trim().min(1).max(4096), profileId: z.string().uuid(),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), csr: z.string().trim().min(1).max(4096),
notBefore: validateCaDateField.optional(), ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
notAfter: validateCaDateField.optional() notBefore: validateCaDateField.optional(),
}), notAfter: validateCaDateField.optional()
})
.refine(
(data) => {
const hasDateFields = data.notBefore || data.notAfter;
const hasTtl = data.ttl;
return !(hasDateFields && hasTtl);
},
{
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
}
)
.refine(
(data) => {
if (data.notBefore && data.notAfter) {
const notBefore = new Date(data.notBefore);
const notAfter = new Date(data.notAfter);
return notBefore < notAfter;
}
return true;
},
{
message: "notBefore must be earlier than notAfter"
}
),
response: { response: {
200: z.object({ 200: z.object({
certificate: z.string().trim(), certificate: z.string().trim(),
@@ -161,22 +214,16 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
}); });
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: profile.projectId, projectId: data.projectId,
event: { event: {
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId certificateId: data.certificateId,
profileName: data.profileName,
commonName: req.body.csr || ""
} }
} }
}); });
@@ -194,48 +241,73 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
schema: { schema: {
hide: false, hide: false,
tags: [ApiDocsTags.PkiCertificates], tags: [ApiDocsTags.PkiCertificates],
body: z.object({ body: z
profileId: z.string().uuid(), .object({
subjectAlternativeNames: z profileId: z.string().uuid(),
.array( subjectAlternativeNames: z
z.object({ .array(
type: z.enum(["dns", "ip"]), z.object({
value: z.string() type: z.nativeEnum(ACMESANType),
}) value: z.string()
) })
.min(1), )
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), .min(1),
keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(), extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notAfter: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
commonName: validateTemplateRegexField.optional(), notAfter: validateCaDateField.optional(),
signatureAlgorithm: z.string().optional(), commonName: validateTemplateRegexField.optional(),
keyAlgorithm: z.string().optional() signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
}), keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
})
.refine(
(data) => {
const hasDateFields = data.notBefore || data.notAfter;
const hasTtl = data.ttl;
return !(hasDateFields && hasTtl);
},
{
message:
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
}
)
.refine(
(data) => {
if (data.notBefore && data.notAfter) {
const notBefore = new Date(data.notBefore);
const notAfter = new Date(data.notAfter);
return notBefore < notAfter;
}
return true;
},
{
message: "notBefore must be earlier than notAfter"
}
),
response: { response: {
200: z.object({ 200: z.object({
orderId: z.string(), orderId: z.string(),
status: z.enum(["pending", "processing", "valid", "invalid"]), status: z.nativeEnum(CertificateOrderStatus),
subjectAlternativeNames: z.array( subjectAlternativeNames: z.array(
z.object({ z.object({
type: z.enum(["dns", "ip"]), type: z.nativeEnum(ACMESANType),
value: z.string(), value: z.string(),
status: z.enum(["pending", "processing", "valid", "invalid"]) status: z.nativeEnum(CertificateOrderStatus)
}) })
), ),
authorizations: z.array( authorizations: z.array(
z.object({ z.object({
identifier: z.object({ identifier: z.object({
type: z.enum(["dns", "ip"]), type: z.nativeEnum(ACMESANType),
value: z.string() value: z.string()
}), }),
status: z.enum(["pending", "processing", "valid", "invalid"]), status: z.nativeEnum(CertificateOrderStatus),
expires: z.string().optional(), expires: z.string().optional(),
challenges: z.array( challenges: z.array(
z.object({ z.object({
type: z.string(), type: z.string(),
status: z.enum(["pending", "processing", "valid", "invalid"]), status: z.nativeEnum(CertificateOrderStatus),
url: z.string(), url: z.string(),
token: z.string() token: z.string()
}) })
@@ -256,7 +328,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
profileId: req.body.profileId, profileId: req.body.profileId,
certificateOrder: { certificateOrder: {
subjectAlternativeNames: req.body.subjectAlternativeNames, altNames: req.body.subjectAlternativeNames,
validity: { validity: {
ttl: req.body.ttl ttl: req.body.ttl
}, },
@@ -270,23 +342,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
} }
}); });
const profile = await server.services.certificateProfile.getProfileById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.body.profileId
});
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: profile.projectId, projectId: data.projectId,
event: { event: {
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
metadata: { metadata: {
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
orderId: data.orderId, orderId: data.orderId,
subjectAlternativeNames: req.body.subjectAlternativeNames.map((san) => `${san.type}:${san.value}`) profileName: data.profileName
} }
} }
}); });
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns";
describe("signatureAlgorithmToAlgCfg", () => {
describe("RSA algorithms", () => {
it("should handle RSA-SHA256 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
});
});
it("should handle RSA-SHA384 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-384",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 4096
});
});
it("should handle RSA-SHA512 correctly", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048);
expect(result).toEqual({
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-512",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
});
});
});
describe("ECDSA algorithms", () => {
it("should handle ECDSA-SHA256 with P-256 curve", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256);
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256"
});
});
it("should handle ECDSA-SHA384 with P-384 curve", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384);
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256"
});
});
it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
});
describe("hash format normalization", () => {
it("should normalize SHA256 to SHA-256", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-256");
});
it("should normalize SHA384 to SHA-384", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384);
expect(result.hash).toBe("SHA-384");
});
it("should normalize SHA512 to SHA-512", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096);
expect(result.hash).toBe("SHA-512");
});
it("should handle SHA1 format", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-1");
});
it("should handle SHA224 format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256);
expect(result.hash).toBe("SHA-224");
});
it("should handle case insensitive hash normalization", () => {
const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA-256");
});
it("should handle already normalized hash formats", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256);
expect(result.hash).toBe("SHA-256");
});
it("should handle SHA-3 family hashes", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048);
expect(result.hash).toBe("SHA3-256");
});
});
describe("dynamic key algorithm support", () => {
it("should support future RSA key sizes", () => {
const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192");
expect(result.name).toBe("RSASSA-PKCS1-v1_5");
expect(result.hash).toBe("SHA-256");
});
it("should support future EC curves", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1");
expect(result.name).toBe("ECDSA");
expect(result.namedCurve).toBe("P-256");
expect(result.hash).toBe("SHA-256");
});
it("should support EC_P384 string format", () => {
const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384");
expect(result).toEqual({
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
});
});
});
});
@@ -99,29 +99,52 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => {
} }
}; };
export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm) => { export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => {
// Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc.
const [keyType, hashType] = signatureAlgorithm.split("-"); const [keyType, hashType] = signatureAlgorithm.split("-");
const normalizeHashType = (hash: string) => {
const upperHash = hash.toUpperCase();
if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1";
if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224";
if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256";
if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384";
if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512";
if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224";
if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256";
if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384";
if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512";
return hash;
};
const normalizedHash = hashType ? normalizeHashType(hashType) : undefined;
switch (keyType) { switch (keyType) {
case "RSA": case "RSA":
return { return {
name: "RSASSA-PKCS1-v1_5", name: "RSASSA-PKCS1-v1_5",
hash: hashType || "SHA-256", hash: normalizedHash || "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]), publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : 2048 modulusLength: keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : 2048
}; };
case "ECDSA": case "ECDSA":
// eslint-disable-next-line no-case-declarations // eslint-disable-next-line no-case-declarations
const namedCurve = keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 ? "P-384" : "P-256"; const is384Curve =
keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384";
// eslint-disable-next-line no-case-declarations
const namedCurve = is384Curve ? "P-384" : "P-256";
return { return {
name: "ECDSA", name: "ECDSA",
namedCurve, namedCurve,
hash: hashType || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256")
}; };
default: default:
// Fallback to key algorithm default // Fallback to key algorithm default
return keyAlgorithmToAlgCfg(keyAlgorithm); return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm);
} }
}; };
@@ -1280,16 +1280,31 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
} }
// Use provided keyAlgorithm if available, otherwise fall back to CA's algorithm
const effectiveKeyAlgorithm = const effectiveKeyAlgorithm =
(keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm); (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm);
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]);
if (signatureAlgorithm) {
const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0];
const isRsaCa = caKeyAlgorithm.startsWith("RSA");
const isEcdsaCa = caKeyAlgorithm.startsWith("EC");
if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) {
// eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "ECDSA" : "unknown";
throw new BadRequestError({
message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
});
}
}
// Determine signing algorithm for certificate signing // Determine signing algorithm for certificate signing
const signingAlg = signatureAlgorithm const signingAlg = signatureAlgorithm
? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm)
: keyGenAlg; : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${commonName}`, name: `CN=${commonName}`,
@@ -1528,7 +1543,9 @@ export const internalCertificateAuthorityServiceFactory = ({
notBefore, notBefore,
notAfter, notAfter,
keyUsages, keyUsages,
extendedKeyUsages extendedKeyUsages,
signatureAlgorithm,
keyAlgorithm
} = dto; } = dto;
let collectionId = pkiCollectionId; let collectionId = pkiCollectionId;
@@ -1633,7 +1650,26 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
} }
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); if (signatureAlgorithm) {
const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA)
const isRsaCa = caKeyAlgorithm.startsWith("RSA");
const isEcdsaCa = caKeyAlgorithm.startsWith("EC");
if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) {
// eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "ECDSA" : "unknown";
throw new BadRequestError({
message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
});
}
}
const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm;
const alg = signatureAlgorithm
? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm)
: keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const csrObj = new x509.Pkcs10CertificateRequest(csr); const csrObj = new x509.Pkcs10CertificateRequest(csr);
@@ -150,6 +150,8 @@ export type TSignCertFromCaDTO =
notAfter?: string; notAfter?: string;
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
} }
| ({ | ({
isInternal: false; isInternal: false;
@@ -165,6 +167,8 @@ export type TSignCertFromCaDTO =
notAfter?: string; notAfter?: string;
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[];
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">); } & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = { export type TGetCaCertificateTemplatesDTO = {
@@ -0,0 +1,186 @@
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum CertKeyUsageType {
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsageType {
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
OCSP_SIGNING = "ocsp_signing",
TIME_STAMPING = "time_stamping"
}
export enum CertIncludeType {
MANDATORY = "mandatory",
OPTIONAL = "optional",
PROHIBIT = "prohibit"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
YEARS = "years"
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
}
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 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;
default:
throw new Error(`Unknown SAN type: ${type}`);
}
};
export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => {
switch (usage) {
case CertKeyUsageType.DIGITAL_SIGNATURE:
return "digitalSignature";
case CertKeyUsageType.KEY_ENCIPHERMENT:
return "keyEncipherment";
case CertKeyUsageType.NON_REPUDIATION:
return "nonRepudiation";
case CertKeyUsageType.DATA_ENCIPHERMENT:
return "dataEncipherment";
case CertKeyUsageType.KEY_AGREEMENT:
return "keyAgreement";
case CertKeyUsageType.KEY_CERT_SIGN:
return "keyCertSign";
case CertKeyUsageType.CRL_SIGN:
return "cRLSign";
case CertKeyUsageType.ENCIPHER_ONLY:
return "encipherOnly";
case CertKeyUsageType.DECIPHER_ONLY:
return "decipherOnly";
default:
return usage;
}
};
export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => {
switch (usage) {
case "digitalSignature":
case "digital_signature":
return CertKeyUsageType.DIGITAL_SIGNATURE;
case "keyEncipherment":
case "key_encipherment":
return CertKeyUsageType.KEY_ENCIPHERMENT;
case "nonRepudiation":
case "non_repudiation":
return CertKeyUsageType.NON_REPUDIATION;
case "dataEncipherment":
case "data_encipherment":
return CertKeyUsageType.DATA_ENCIPHERMENT;
case "keyAgreement":
case "key_agreement":
return CertKeyUsageType.KEY_AGREEMENT;
case "keyCertSign":
case "key_cert_sign":
return CertKeyUsageType.KEY_CERT_SIGN;
case "cRLSign":
case "crl_sign":
return CertKeyUsageType.CRL_SIGN;
case "encipherOnly":
case "encipher_only":
return CertKeyUsageType.ENCIPHER_ONLY;
case "decipherOnly":
case "decipher_only":
return CertKeyUsageType.DECIPHER_ONLY;
default:
throw new Error(`Unknown key usage: ${usage}`);
}
};
export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => {
switch (usage) {
case CertExtendedKeyUsageType.CLIENT_AUTH:
return "clientAuth";
case CertExtendedKeyUsageType.SERVER_AUTH:
return "serverAuth";
case CertExtendedKeyUsageType.CODE_SIGNING:
return "codeSigning";
case CertExtendedKeyUsageType.EMAIL_PROTECTION:
return "emailProtection";
case CertExtendedKeyUsageType.OCSP_SIGNING:
return "ocspSigning";
case CertExtendedKeyUsageType.TIME_STAMPING:
return "timeStamping";
default:
return usage;
}
};
export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => {
switch (usage) {
case "clientAuth":
case "client_auth":
return CertExtendedKeyUsageType.CLIENT_AUTH;
case "serverAuth":
case "server_auth":
return CertExtendedKeyUsageType.SERVER_AUTH;
case "codeSigning":
case "code_signing":
return CertExtendedKeyUsageType.CODE_SIGNING;
case "emailProtection":
case "email_protection":
return CertExtendedKeyUsageType.EMAIL_PROTECTION;
case "ocspSigning":
case "ocsp_signing":
return CertExtendedKeyUsageType.OCSP_SIGNING;
case "timeStamping":
case "time_stamping":
return CertExtendedKeyUsageType.TIME_STAMPING;
default:
throw new Error(`Unknown extended key usage: ${usage}`);
}
};
export 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);
@@ -1,34 +1,39 @@
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
mapExtendedKeyUsageToLegacy,
mapKeyUsageToLegacy,
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "./certificate-constants";
interface CertificateRequestInput { interface CertificateRequestInput {
keyUsages?: string[]; keyUsages?: string[];
extendedKeyUsages?: string[]; extendedKeyUsages?: string[];
} }
export const mapEnumsForValidation = <T extends CertificateRequestInput>(request: T): T => { export const mapEnumsForValidation = <T extends CertificateRequestInput>(request: T): T => {
const keyUsageMapping: Record<string, string> = { const mapKeyUsage = (usage: string): string => {
digitalSignature: "digital_signature", try {
keyEncipherment: "key_encipherment", return mapLegacyKeyUsageToStandard(usage);
nonRepudiation: "non_repudiation", } catch {
dataEncipherment: "data_encipherment", return usage;
keyAgreement: "key_agreement", }
keyCertSign: "key_cert_sign",
cRLSign: "crl_sign",
encipherOnly: "encipher_only",
decipherOnly: "decipher_only"
}; };
const extendedKeyUsageMapping: Record<string, string> = { const mapExtendedKeyUsage = (usage: string): string => {
serverAuth: "server_auth", try {
clientAuth: "client_auth", return mapLegacyExtendedKeyUsageToStandard(usage);
codeSigning: "code_signing", } catch {
emailProtection: "email_protection", return usage;
timeStamping: "time_stamping", }
ocspSigning: "ocsp_signing"
}; };
return { return {
...request, ...request,
keyUsages: request.keyUsages?.map((usage: string) => keyUsageMapping[usage] || usage), keyUsages: request.keyUsages?.map(mapKeyUsage),
extendedKeyUsages: request.extendedKeyUsages?.map((usage: string) => extendedKeyUsageMapping[usage] || usage) extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage)
} as T; } as T;
}; };
@@ -51,27 +56,28 @@ export const buildCertificateSubjectFromTemplate = (
): Record<string, string | undefined> => { ): Record<string, string | undefined> => {
const subject: Record<string, string> = {}; const subject: Record<string, string> = {};
const attributeMap: Record<string, string> = { const attributeMap: Record<string, string> = {
common_name: "commonName", common_name: "commonName"
organization_name: "organization",
organization_unit: "organizationUnit",
locality: "locality",
state: "state",
country: "country",
email: "email",
street_address: "streetAddress",
postal_code: "postalCode"
}; };
if (!templateAttributes || templateAttributes.length === 0) { if (!templateAttributes || templateAttributes.length === 0) {
Object.entries(attributeMap).forEach(([templateKey, requestKey]) => { throw new Error(
const value = request[requestKey]; "Template must define allowed certificate attributes. Cannot issue certificate without template attribute constraints."
if (value && typeof value === "string") { );
subject[templateKey] = value;
}
});
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) => { templateAttributes.forEach((attr) => {
if (attr.include === "prohibit") { if (attr.include === "prohibit") {
return; return;
@@ -101,12 +107,28 @@ export const buildSubjectAlternativeNamesFromTemplate = (
} }
if (!templateSans || templateSans.length === 0) { if (!templateSans || templateSans.length === 0) {
return request.subjectAlternativeNames.map((san) => san.value).join(","); 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 "";
} }
const allowedSans: string[] = []; const templateSanTypes = new Set(templateSans.map((san) => san.type));
const prohibitedTypes = new Set(templateSans.filter((san) => san.include === "prohibit").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) => { request.subjectAlternativeNames.forEach((san) => {
const sanType = san.type === "dns_name" ? "dns_name" : san.type; const sanType = san.type === "dns_name" ? "dns_name" : san.type;
if (!prohibitedTypes.has(sanType)) { if (!prohibitedTypes.has(sanType)) {
@@ -116,3 +138,39 @@ export const buildSubjectAlternativeNamesFromTemplate = (
return allowedSans.join(","); return allowedSans.join(",");
}; };
export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => {
return mapLegacyKeyUsageToStandard(usage);
};
export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => {
return mapKeyUsageToLegacy(usage) as CertKeyUsage;
};
export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => {
return mapLegacyExtendedKeyUsageToStandard(usage);
};
export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => {
return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage;
};
export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => {
return usages?.map(convertLegacyKeyUsage);
};
export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => {
return usages?.map(convertToLegacyKeyUsage);
};
export const convertExtendedKeyUsageArrayFromLegacy = (
usages?: CertExtendedKeyUsage[]
): CertExtendedKeyUsageType[] | undefined => {
return usages?.map(convertLegacyExtendedKeyUsage);
};
export const convertExtendedKeyUsageArrayToLegacy = (
usages?: CertExtendedKeyUsageType[]
): CertExtendedKeyUsage[] | undefined => {
return usages?.map(convertToLegacyExtendedKeyUsage);
};
@@ -8,6 +8,7 @@ import { TCertificateAuthorityDALFactory } from "@app/services/certificate-autho
import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { TCertificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
@@ -56,6 +57,10 @@ export const certificateEstV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate profile not found" }); throw new NotFoundError({ message: "Certificate profile not found" });
} }
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) { if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
} }
@@ -141,6 +146,10 @@ export const certificateEstV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate profile not found" }); throw new NotFoundError({ message: "Certificate profile not found" });
} }
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) { if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
} }
@@ -237,6 +246,10 @@ export const certificateEstV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate profile not found" }); throw new NotFoundError({ message: "Certificate profile not found" });
} }
if (profile.enrollmentType !== EnrollmentType.EST) {
throw new BadRequestError({ message: "Profile is not configured for EST enrollment" });
}
if (!profile.estConfigId) { if (!profile.estConfigId) {
throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); throw new BadRequestError({ message: "EST enrollment not configured for this profile" });
} }
@@ -280,8 +293,14 @@ export const certificateEstV3ServiceFactory = ({
kmsService kmsService
}); });
const certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); const certificateChain = extractX509CertFromChain(caCertChain);
if (!certificateChain || certificateChain.length === 0) {
throw new BadRequestError({
message: "Invalid CA certificate chain: unable to extract certificates"
});
}
const certificates = certificateChain.map((cert) => new x509.X509Certificate(cert));
const caCertificate = new x509.X509Certificate(caCert); const caCertificate = new x509.X509Certificate(caCert);
return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]);
}; };
@@ -31,42 +31,72 @@ export const createCertificateProfileSchema = z
}) })
.refine( .refine(
(data) => { (data) => {
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { if (data.enrollmentType === EnrollmentType.EST) {
return false; if (!data.estConfig) {
return false;
}
if (data.apiConfig) {
return false;
}
} }
if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { if (data.enrollmentType === EnrollmentType.API) {
return false; if (!data.apiConfig) {
return false;
}
if (data.estConfig) {
return false;
}
} }
return true; return true;
}, },
{ {
message: "Config must be provided based on enrollment type" message:
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
} }
); );
export const updateCertificateProfileSchema = z.object({ export const updateCertificateProfileSchema = z
slug: z .object({
.string() slug: z
.min(1) .string()
.max(255) .min(1)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") .max(255)
.optional(), .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
description: z.string().max(1000).optional(), .optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(), description: z.string().max(1000).optional(),
estConfig: z enrollmentType: z.nativeEnum(EnrollmentType).optional(),
.object({ estConfig: z
disableBootstrapCaValidation: z.boolean().default(false), .object({
passphrase: z.string().min(1), disableBootstrapCaValidation: z.boolean().default(false),
encryptedCaChain: z.string() passphrase: z.string().min(1),
}) encryptedCaChain: z.string()
.optional(), })
apiConfig: z .optional(),
.object({ apiConfig: z
autoRenew: z.boolean().default(false), .object({
autoRenewDays: z.number().min(1).max(365).optional() autoRenew: z.boolean().default(false),
}) autoRenewDays: z.number().min(1).max(365).optional()
.optional() })
}); .optional()
})
.refine(
(data) => {
if (data.enrollmentType === EnrollmentType.EST) {
if (data.apiConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (data.estConfig) {
return false;
}
}
return true;
},
{
message: "Cannot have EST config with API enrollment type or API config with EST enrollment type."
}
);
export const getCertificateProfileByIdSchema = z.object({ export const getCertificateProfileByIdSchema = z.object({
id: z.string().uuid() id: z.string().uuid()
@@ -122,9 +122,7 @@ describe("CertificateProfileService", () => {
create: vi.fn().mockResolvedValue({ id: "api-config-123" }), create: vi.fn().mockResolvedValue({ id: "api-config-123" }),
findById: vi.fn(), findById: vi.fn(),
updateById: vi.fn(), updateById: vi.fn(),
deleteById: vi.fn(),
findProfilesForAutoRenewal: vi.fn(), findProfilesForAutoRenewal: vi.fn(),
isConfigInUse: vi.fn(),
transaction: vi.fn(), transaction: vi.fn(),
find: vi.fn(), find: vi.fn(),
findOne: vi.fn(), findOne: vi.fn(),
@@ -136,8 +134,6 @@ describe("CertificateProfileService", () => {
create: vi.fn().mockResolvedValue({ id: "est-config-123" }), create: vi.fn().mockResolvedValue({ id: "est-config-123" }),
findById: vi.fn(), findById: vi.fn(),
updateById: vi.fn(), updateById: vi.fn(),
deleteById: vi.fn(),
isConfigInUse: vi.fn(),
transaction: vi.fn(), transaction: vi.fn(),
find: vi.fn(), find: vi.fn(),
findOne: vi.fn(), findOne: vi.fn(),
@@ -158,6 +154,12 @@ describe("CertificateProfileService", () => {
throwUnlessCan: vi.fn() throwUnlessCan: vi.fn()
} as any); } as any);
// Mock the transaction method to execute the callback and return the result
(mockCertificateProfileDAL.transaction as any).mockImplementation(async (fn: any) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await
return await fn();
});
service = certificateProfileServiceFactory({ service = certificateProfileServiceFactory({
certificateProfileDAL: mockCertificateProfileDAL, certificateProfileDAL: mockCertificateProfileDAL,
certificateTemplateV2DAL: mockCertificateTemplateV2DAL, certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
@@ -188,7 +190,10 @@ describe("CertificateProfileService", () => {
(mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate);
(mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.create as any).mockResolvedValue(sampleProfile); (mockCertificateProfileDAL.create as any).mockResolvedValue({
...sampleProfile,
enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included
});
}); });
it("should create profile successfully", async () => { it("should create profile successfully", async () => {
@@ -201,16 +206,19 @@ describe("CertificateProfileService", () => {
expect(result).toEqual(sampleProfile); expect(result).toEqual(sampleProfile);
expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123");
expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123");
expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith({ expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith(
slug: "new-profile", {
description: "New test profile", slug: "new-profile",
enrollmentType: EnrollmentType.API, description: "New test profile",
caId: "ca-123", enrollmentType: EnrollmentType.API,
certificateTemplateId: "template-123", caId: "ca-123",
apiConfigId: "api-config-123", certificateTemplateId: "template-123",
estConfigId: null, apiConfigId: "api-config-123",
projectId: "project-123" estConfigId: null,
}); projectId: "project-123"
},
undefined
);
}); });
it("should throw NotFoundError when certificate template not found", async () => { it("should throw NotFoundError when certificate template not found", async () => {
@@ -318,7 +326,11 @@ describe("CertificateProfileService", () => {
beforeEach(() => { beforeEach(() => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.updateById as any).mockResolvedValue({ ...sampleProfile, ...updateData }); (mockCertificateProfileDAL.updateById as any).mockResolvedValue({
...sampleProfile,
...updateData,
enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included
});
}); });
it("should update profile successfully", async () => { it("should update profile successfully", async () => {
@@ -330,7 +342,7 @@ describe("CertificateProfileService", () => {
expect(result.slug).toBe("updated-profile"); expect(result.slug).toBe("updated-profile");
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData); expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined);
}); });
it("should throw NotFoundError when profile not found", async () => { it("should throw NotFoundError when profile not found", async () => {
@@ -720,11 +732,14 @@ describe("CertificateProfileService", () => {
}); });
expect(result.enrollmentType).toBe(EnrollmentType.EST); expect(result.enrollmentType).toBe(EnrollmentType.EST);
expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith({ expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith(
disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, {
hashedPassphrase: "mocked-hash", disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation,
encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64") hashedPassphrase: "mocked-hash",
}); encryptedCaChain: Buffer.from(estProfileData.estConfig.encryptedCaChain, "base64")
},
undefined
);
}); });
it("should handle profile slug uniqueness validation", async () => { it("should handle profile slug uniqueness validation", async () => {
@@ -772,7 +787,8 @@ describe("CertificateProfileService", () => {
(mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.create as any).mockResolvedValue({ (mockCertificateProfileDAL.create as any).mockResolvedValue({
...sampleProfile, ...sampleProfile,
apiConfigId: "api-config-123" apiConfigId: "api-config-123",
enrollmentType: EnrollmentType.API
}); });
const result = await service.createProfile({ const result = await service.createProfile({
@@ -781,10 +797,13 @@ describe("CertificateProfileService", () => {
data: autoRenewData data: autoRenewData
}); });
expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith({ expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith(
autoRenew: true, {
autoRenewDays: 7 autoRenew: true,
}); autoRenewDays: 7
},
undefined
);
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
}); });
@@ -1076,7 +1095,8 @@ describe("CertificateProfileService", () => {
(mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null);
(mockCertificateProfileDAL.create as any).mockResolvedValue({ (mockCertificateProfileDAL.create as any).mockResolvedValue({
...sampleProfile, ...sampleProfile,
slug: invalidSlugData.slug slug: invalidSlugData.slug,
enrollmentType: EnrollmentType.API
}); });
const result = await service.createProfile({ const result = await service.createProfile({
@@ -49,70 +49,6 @@ const convertDalToService = (dalResult: Record<string, unknown>): TCertificatePr
} as TCertificateProfile; } as TCertificateProfile;
}; };
const validateEnrollmentConfig = async (data: {
enrollmentType: EnrollmentType;
estConfig?: TEstConfigData | null;
apiConfig?: TApiConfigData | null;
}): Promise<void> => {
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfig) {
throw new ForbiddenRequestError({
message: "EST enrollment type requires EST configuration"
});
}
if (data.apiConfig) {
throw new ForbiddenRequestError({
message: "EST enrollment type cannot have API configuration"
});
}
} else if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) {
throw new ForbiddenRequestError({
message: "API enrollment type requires API configuration"
});
}
if (data.estConfig) {
throw new ForbiddenRequestError({
message: "API enrollment type cannot have EST configuration"
});
}
}
};
const validateEnrollmentConfigForUpdate = async (data: {
enrollmentType: EnrollmentType;
estConfigId?: string | null;
apiConfigId?: string | null;
}): Promise<void> => {
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfigId) {
throw new ForbiddenRequestError({
message: "EST enrollment type requires EST configuration ID"
});
}
if (data.apiConfigId) {
throw new ForbiddenRequestError({
message: "EST enrollment type cannot have API configuration ID"
});
}
} else if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfigId) {
throw new ForbiddenRequestError({
message: "API enrollment type requires API configuration ID"
});
}
if (data.estConfigId) {
throw new ForbiddenRequestError({
message: "API enrollment type cannot have EST configuration ID"
});
}
}
};
const hasEnrollmentConfigChanges = (data: TCertificateProfileUpdate): boolean => {
return !!(data.enrollmentType || data.estConfigId || data.apiConfigId);
};
export const certificateProfileServiceFactory = ({ export const certificateProfileServiceFactory = ({
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2DAL, certificateTemplateV2DAL,
@@ -169,43 +105,61 @@ export const certificateProfileServiceFactory = ({
}); });
} }
// Validate enrollment type configuration // Validate enrollment configuration requirements
await validateEnrollmentConfig({ if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
enrollmentType: data.enrollmentType, throw new ForbiddenRequestError({
estConfig: data.estConfig, message: "EST enrollment requires EST configuration"
apiConfig: data.apiConfig
});
// Create enrollment configs based on type
let estConfigId: string | null = null;
let apiConfigId: string | null = null;
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
const appCfg = getConfig();
// Hash the passphrase
const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS);
const estConfig = await estEnrollmentConfigDAL.create({
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation,
hashedPassphrase,
encryptedCaChain: Buffer.from(data.estConfig.encryptedCaChain, "base64")
}); });
estConfigId = estConfig.id; }
} else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) {
const apiConfig = await apiEnrollmentConfigDAL.create({ throw new ForbiddenRequestError({
autoRenew: data.apiConfig.autoRenew, message: "API enrollment requires API configuration"
autoRenewDays: data.apiConfig.autoRenewDays
}); });
apiConfigId = apiConfig.id;
} }
// Create the profile with the created config IDs // Create enrollment configs and profile
const { estConfig, apiConfig, ...profileData } = data; const profile = await certificateProfileDAL.transaction(async (tx) => {
const profile = await certificateProfileDAL.create({ let estConfigId: string | null = null;
...profileData, let apiConfigId: string | null = null;
projectId,
estConfigId, if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
apiConfigId const appCfg = getConfig();
// Hash the passphrase
const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS);
const estConfig = await estEnrollmentConfigDAL.create(
{
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation,
hashedPassphrase,
encryptedCaChain: Buffer.from(data.estConfig.encryptedCaChain, "base64")
},
tx
);
estConfigId = estConfig.id;
} else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) {
const apiConfig = await apiEnrollmentConfigDAL.create(
{
autoRenew: data.apiConfig.autoRenew,
autoRenewDays: data.apiConfig.autoRenewDays
},
tx
);
apiConfigId = apiConfig.id;
}
// Create the profile with the created config IDs
const { estConfig, apiConfig, ...profileData } = data;
const profileResult = await certificateProfileDAL.create(
{
...profileData,
projectId,
estConfigId,
apiConfigId
},
tx
);
return profileResult;
}); });
return convertDalToService(profile); return convertDalToService(profile);
@@ -268,37 +222,40 @@ export const certificateProfileServiceFactory = ({
} }
} }
if (hasEnrollmentConfigChanges(data)) {
const mergedData = { ...existingProfile, ...data };
await validateEnrollmentConfigForUpdate({
enrollmentType: mergedData.enrollmentType as EnrollmentType,
estConfigId: mergedData.estConfigId,
apiConfigId: mergedData.apiConfigId
});
}
const { estConfig, apiConfig, ...profileUpdateData } = data; const { estConfig, apiConfig, ...profileUpdateData } = data;
if (estConfig && existingProfile.estConfigId) { const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, { if (estConfig && existingProfile.estConfigId) {
disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation, await estEnrollmentConfigDAL.updateById(
...(estConfig.passphrase && { existingProfile.estConfigId,
hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS) {
}), disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation,
...(estConfig.caChain && { ...(estConfig.passphrase && {
encryptedCaChain: Buffer.from(estConfig.caChain, "base64") hashedPassphrase: await crypto.hashing().createHash(estConfig.passphrase, getConfig().SALT_ROUNDS)
}) }),
}); ...(estConfig.caChain && {
} encryptedCaChain: Buffer.from(estConfig.caChain, "base64")
})
},
tx
);
}
if (apiConfig && existingProfile.apiConfigId) { if (apiConfig && existingProfile.apiConfigId) {
await apiEnrollmentConfigDAL.updateById(existingProfile.apiConfigId, { await apiEnrollmentConfigDAL.updateById(
autoRenew: apiConfig.autoRenew, existingProfile.apiConfigId,
autoRenewDays: apiConfig.autoRenewDays {
}); autoRenew: apiConfig.autoRenew,
} autoRenewDays: apiConfig.autoRenewDays
},
tx
);
}
const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx);
return profileResult;
});
const updatedProfile = await certificateProfileDAL.updateById(profileId, profileUpdateData);
return convertDalToService(updatedProfile); return convertDalToService(updatedProfile);
}; };
@@ -14,6 +14,10 @@ import {
export type TCertificateTemplateV2DALFactory = ReturnType<typeof certificateTemplateV2DALFactory>; export type TCertificateTemplateV2DALFactory = ReturnType<typeof certificateTemplateV2DALFactory>;
interface CountResult {
count: string;
}
export const certificateTemplateV2DALFactory = (db: TDbClient) => { export const certificateTemplateV2DALFactory = (db: TDbClient) => {
const certificateTemplateV2Orm = ormify(db, TableName.CertificateTemplateV2); const certificateTemplateV2Orm = ormify(db, TableName.CertificateTemplateV2);
@@ -199,12 +203,33 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
.count("*") .count("*")
.first(); .first();
return parseInt(profileCount || "0", 10) > 0; const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0;
const certCount = await (tx || db)(TableName.Certificate)
.where({ certificateTemplateId: templateId })
.count("*")
.first();
const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0;
return profileUsage || certUsage;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" }); throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" });
} }
}; };
const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => {
try {
const profiles = await (tx || db)(TableName.CertificateProfile)
.select("id", "slug", "description")
.where({ certificateTemplateId: templateId });
return profiles;
} catch (error) {
throw new DatabaseError({ error, name: "Get profiles using certificate template v2" });
}
};
return { return {
...certificateTemplateV2Orm, ...certificateTemplateV2Orm,
create, create,
@@ -214,6 +239,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => {
findByProjectId, findByProjectId,
countByProjectId, countByProjectId,
findBySlugAndProjectId, findBySlugAndProjectId,
isTemplateInUse isTemplateInUse,
getProfilesUsingTemplate
}; };
}; };
@@ -1,13 +1,22 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
const attributeTypeSchema = z.enum(["common_name"]); import { slugSchema } from "@app/server/lib/schemas";
import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
const includeTypeSchema = z.enum(["mandatory", "optional", "prohibit"]); const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType);
const sanTypeSchema = z.enum(["dns_name", "ip_address", "email", "uri"]); const includeTypeSchema = z.nativeEnum(CertIncludeType);
const durationUnitSchema = z.enum(["days", "months", "years"]); const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const durationUnitSchema = z.nativeEnum(CertDurationUnit);
export const templateV2AttributeSchema = z export const templateV2AttributeSchema = z
.object({ .object({
@@ -17,32 +26,43 @@ export const templateV2AttributeSchema = z
}) })
.refine( .refine(
(data) => { (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.include === "mandatory" && (!data.value || data.value.length > 1)) {
return false; return false;
} }
return true; return true;
}, },
{ {
message: "Mandatory attributes can only have one value or no value (empty)" message: "Common name can only have one value. Mandatory attributes can only have one value or no value (empty)"
} }
); );
export const templateV2KeyUsagesSchema = z.object({ export const templateV2KeyUsagesSchema = z.object({
requiredUsages: z.object({ requiredUsages: z
all: z.array(z.string()) .object({
}), all: z.array(z.nativeEnum(CertKeyUsageType))
optionalUsages: z.object({ })
all: z.array(z.string()) .optional(),
}) optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertKeyUsageType))
})
.optional()
}); });
export const templateV2ExtendedKeyUsagesSchema = z.object({ export const templateV2ExtendedKeyUsagesSchema = z.object({
requiredUsages: z.object({ requiredUsages: z
all: z.array(z.string()) .object({
}), all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
optionalUsages: z.object({ })
all: z.array(z.string()) .optional(),
}) optionalUsages: z
.object({
all: z.array(z.nativeEnum(CertExtendedKeyUsageType))
})
.optional()
}); });
export const templateV2SanSchema = z export const templateV2SanSchema = z
@@ -76,26 +96,30 @@ export const templateV2ValiditySchema = z.object({
.optional() .optional()
}); });
export const templateV2SignatureAlgorithmSchema = z.object({ export const templateV2SignatureAlgorithmSchema = z
allowedAlgorithms: z.array(z.string()).min(1), .object({
defaultAlgorithm: z.string() 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({ export const templateV2KeyAlgorithmSchema = z
allowedKeyTypes: z.array(z.string()).min(1), .object({
defaultKeyType: z.string() 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({ export const createCertificateTemplateV2Schema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
slug: z slug: slugSchema({ min: 1, max: 255 }),
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(), attributes: z.array(templateV2AttributeSchema).min(1),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema,
extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(),
subjectAlternativeNames: z.array(templateV2SanSchema).optional(), subjectAlternativeNames: z.array(templateV2SanSchema).optional(),
validity: templateV2ValiditySchema.optional(), validity: templateV2ValiditySchema.optional(),
@@ -104,12 +128,7 @@ export const createCertificateTemplateV2Schema = z.object({
}); });
export const updateCertificateTemplateV2Schema = z.object({ export const updateCertificateTemplateV2Schema = z.object({
slug: z slug: slugSchema({ min: 1, max: 255 }).optional(),
.string()
.min(1)
.max(255)
.regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens")
.optional(),
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
attributes: z.array(templateV2AttributeSchema).optional(), attributes: z.array(templateV2AttributeSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -126,7 +145,7 @@ export const getCertificateTemplateV2ByIdSchema = z.object({
export const getCertificateTemplateV2BySlugSchema = z.object({ export const getCertificateTemplateV2BySlugSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
slug: z.string().min(1) slug: slugSchema()
}); });
export const listCertificateTemplatesV2Schema = z.object({ export const listCertificateTemplatesV2Schema = z.object({
@@ -142,8 +161,8 @@ export const deleteCertificateTemplateV2Schema = z.object({
export const certificateRequestSchema = z.object({ export const certificateRequestSchema = z.object({
commonName: z.string().optional(), commonName: z.string().optional(),
keyUsages: z.array(z.string()).optional(), keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
extendedKeyUsages: z.array(z.string()).optional(), extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
subjectAlternativeNames: z subjectAlternativeNames: z
.array( .array(
z.object({ z.object({
File diff suppressed because it is too large Load Diff
@@ -12,13 +12,13 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { CertIncludeType, CertSubjectAttributeType } from "../certificate-common/certificate-constants";
import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal";
import { import {
TCertificateRequest, TCertificateRequest,
TCertificateTemplateV2, TCertificateTemplateV2,
TCertificateTemplateV2Insert, TCertificateTemplateV2Insert,
TCertificateTemplateV2Update, TCertificateTemplateV2Update,
TTemplateV2Policy,
TTemplateValidationResult TTemplateValidationResult
} from "./certificate-template-v2-types"; } from "./certificate-template-v2-types";
@@ -70,8 +70,41 @@ export const certificateTemplateV2ServiceFactory = ({
} }
}; };
const getRequestAttributeValue = (request: TCertificateRequest, attrType: string): string | undefined => { 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) {
throw new ForbiddenRequestError({
message: `Multiple mandatory values found for subject attribute type '${type}'. Only one mandatory value is allowed per attribute type.`
});
}
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.`
});
}
}
};
const getRequestAttributeValue = (
request: TCertificateRequest,
attrType: CertSubjectAttributeType | string
): string | undefined => {
switch (attrType) { switch (attrType) {
case CertSubjectAttributeType.COMMON_NAME:
case "common_name": case "common_name":
return request.commonName; return request.commonName;
default: default:
@@ -79,44 +112,6 @@ export const certificateTemplateV2ServiceFactory = ({
} }
}; };
const validateTemplatePolicy = (policy: Partial<TTemplateV2Policy>): void => {
if (!policy) {
throw new Error("Template policy is required");
}
if (!policy.attributes || policy.attributes.length === 0) {
throw new Error("Template policy must include attributes array");
}
if (!policy.keyUsages || !policy.keyUsages.requiredUsages || !policy.keyUsages.optionalUsages) {
throw new Error("Template policy must include valid key usages configuration");
}
if (policy.signatureAlgorithm) {
if (!policy.signatureAlgorithm.allowedAlgorithms.includes(policy.signatureAlgorithm.defaultAlgorithm)) {
throw new Error("Default signature algorithm must be in allowed algorithms list");
}
}
if (policy.keyAlgorithm) {
if (!policy.keyAlgorithm.allowedKeyTypes.includes(policy.keyAlgorithm.defaultKeyType)) {
throw new Error("Default key algorithm must be in allowed key types list");
}
}
};
const hasAnyPolicyField = (data: TCertificateTemplateV2Update): boolean => {
return !!(
data.attributes ||
data.keyUsages ||
data.extendedKeyUsages ||
data.subjectAlternativeNames ||
data.validity ||
data.signatureAlgorithm ||
data.keyAlgorithm
);
};
const generateTemplateSlug = (baseSlug?: string): string => { const generateTemplateSlug = (baseSlug?: string): string => {
if (baseSlug) { if (baseSlug) {
return slugify(baseSlug); return slugify(baseSlug);
@@ -139,177 +134,274 @@ export const certificateTemplateV2ServiceFactory = ({
return randomSlug; return randomSlug;
}; };
const isWildcardPattern = (value: string): boolean => {
return value.includes("*");
};
const createWildcardRegex = (pattern: string): RegExp => {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
const regexPattern = escaped.replace(/\*/g, ".*");
return new RE2(`^${regexPattern}$`);
};
const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"SHA256-RSA": "RSA-SHA256",
"SHA384-RSA": "RSA-SHA384",
"SHA512-RSA": "RSA-SHA512",
"SHA256-ECDSA": "ECDSA-SHA256",
"SHA384-ECDSA": "ECDSA-SHA384",
"SHA512-ECDSA": "ECDSA-SHA512"
};
return mapping[templateFormat] || templateFormat;
};
const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => {
const mapping: Record<string, string> = {
"RSA-2048": "RSA_2048",
"RSA-4096": "RSA_4096",
"ECDSA-P256": "EC_prime256v1",
"ECDSA-P384": "EC_secp384r1"
};
return mapping[templateFormat] || templateFormat;
};
const validateValueAgainstConstraints = (
value: string,
allowedValues: string[],
fieldName: string
): { isValid: boolean; error?: string } => {
if (!allowedValues || allowedValues.length === 0) {
return { isValid: true };
}
const hasWildcards = allowedValues.some(isWildcardPattern);
for (const allowedValue of allowedValues) {
if (isWildcardPattern(allowedValue)) {
try {
const regex = createWildcardRegex(allowedValue);
if (regex.test(value)) {
return { isValid: true };
}
} catch {
if (allowedValue === value) {
return { isValid: true };
}
}
} else if (allowedValue === value) {
return { isValid: true };
}
}
if (hasWildcards) {
return {
isValid: false,
error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}`
};
}
return {
isValid: false,
error: `${fieldName} value '${value}' is not in allowed values list`
};
};
const validateRequestAgainstPolicy = ( const validateRequestAgainstPolicy = (
template: TCertificateTemplateV2, template: TCertificateTemplateV2,
request: TCertificateRequest request: TCertificateRequest
): TTemplateValidationResult => { ): TTemplateValidationResult => {
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
const templateAttributeTypes = new Set(template.attributes?.map((attr) => attr.type) || []);
const attributePoliciesByType = new Map<string, typeof template.attributes>();
template.attributes?.forEach((attrPolicy) => { template.attributes?.forEach((attrPolicy) => {
const requestValue = getRequestAttributeValue(request, attrPolicy.type); const existing = attributePoliciesByType.get(attrPolicy.type) || [];
attributePoliciesByType.set(attrPolicy.type, [...existing, attrPolicy]);
if (attrPolicy.include === "mandatory") {
if (!requestValue) {
errors.push(`${attrPolicy.type} is mandatory but not provided in request`);
} else if (attrPolicy.value && attrPolicy.value.length > 0) {
// Check if the request value matches any allowed pattern
const hasWildcards = attrPolicy.value.some((val) => val.includes("*"));
const isValidValue = attrPolicy.value.some((allowedValue) => {
if (allowedValue.includes("*")) {
// Handle wildcard patterns
const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(requestValue);
}
return allowedValue === requestValue;
});
if (!isValidValue) {
if (hasWildcards) {
errors.push(
`${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}`
);
} else {
errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
}
}
}
}
if (attrPolicy.include === "prohibit" && requestValue) {
errors.push(`${attrPolicy.type} is prohibited by template policy`);
}
if (attrPolicy.include === "optional" && requestValue && attrPolicy.value && attrPolicy.value.length > 0) {
const hasWildcards = attrPolicy.value.some((val) => val.includes("*"));
const isValidValue = attrPolicy.value.some((allowedValue) => {
if (allowedValue.includes("*")) {
// Handle wildcard patterns - escape dots and replace * with .*
const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(requestValue);
}
return allowedValue === requestValue;
});
if (!isValidValue) {
if (hasWildcards) {
errors.push(
`${attrPolicy.type} value '${requestValue}' does not match allowed patterns: ${attrPolicy.value.join(", ")}`
);
} else {
errors.push(`${attrPolicy.type} value '${requestValue}' is not in allowed values list`);
}
}
}
}); });
if (template.keyUsages) { for (const [attrType, policies] of attributePoliciesByType) {
const missingRequired = template.keyUsages.requiredUsages.all.filter( const requestValue = getRequestAttributeValue(request, attrType);
(usage) => !request.keyUsages?.includes(usage)
); const hasMandatory = policies.some((p) => p.include === CertIncludeType.MANDATORY);
if (missingRequired.length > 0) { const hasProhibit = policies.some((p) => p.include === CertIncludeType.PROHIBIT);
errors.push(`Missing required key usages: ${missingRequired.join(", ")}`);
if (hasProhibit && requestValue) {
errors.push(`${attrType} is prohibited by template policy`);
// eslint-disable-next-line no-continue
continue;
} }
if (request.keyUsages) { if (hasMandatory && !requestValue) {
const allAllowedUsages = [...template.keyUsages.requiredUsages.all, ...template.keyUsages.optionalUsages.all]; errors.push(`${attrType} is mandatory but not provided in request`);
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); // eslint-disable-next-line no-continue
if (invalidUsages.length > 0) { continue;
errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); }
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);
}
} }
} }
} }
const requestAttributeTypes: CertSubjectAttributeType[] = [];
if (request.commonName) requestAttributeTypes.push(CertSubjectAttributeType.COMMON_NAME);
for (const requestAttrType of requestAttributeTypes) {
if (!templateAttributeTypes.has(requestAttrType)) {
errors.push(`${requestAttrType} is not allowed by template policy (not defined in template)`);
}
}
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)
);
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 || [])
];
if (allAllowedUsages.length > 0) {
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) {
const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter( if (template.extendedKeyUsages.requiredUsages && template.extendedKeyUsages.requiredUsages.all.length > 0) {
(usage) => !request.extendedKeyUsages?.includes(usage) const missingRequired = template.extendedKeyUsages.requiredUsages.all.filter(
); (usage) => !request.extendedKeyUsages?.includes(usage)
if (missingRequired.length > 0) { );
errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); if (missingRequired.length > 0) {
} errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`);
if (request.extendedKeyUsages) {
const allAllowedUsages = [
...template.extendedKeyUsages.requiredUsages.all,
...template.extendedKeyUsages.optionalUsages.all
];
const invalidUsages = request.extendedKeyUsages.filter((usage) => !allAllowedUsages.includes(usage));
if (invalidUsages.length > 0) {
errors.push(`Invalid extended key usages: ${invalidUsages.join(", ")}`);
} }
} }
if (
request.extendedKeyUsages &&
(template.extendedKeyUsages.requiredUsages || template.extendedKeyUsages.optionalUsages)
) {
const allAllowedUsages = [
...(template.extendedKeyUsages.requiredUsages?.all || []),
...(template.extendedKeyUsages.optionalUsages?.all || [])
];
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(", ")}`);
}
}
}
} 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) => { template.subjectAlternativeNames?.forEach((sanPolicy) => {
const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanPolicy.type) || []; const existing = sanPoliciesByType.get(sanPolicy.type) || [];
sanPoliciesByType.set(sanPolicy.type, [...existing, sanPolicy]);
if (sanPolicy.include === "mandatory") {
if (requestSans.length === 0) {
errors.push(`${sanPolicy.type} SAN is mandatory but not provided in request`);
} else if (sanPolicy.value && sanPolicy.value.length > 0) {
const hasWildcards = sanPolicy.value.some((val) => val.includes("*"));
requestSans.forEach((san) => {
const isValidValue = sanPolicy.value!.some((allowedValue) => {
if (allowedValue.includes("*")) {
// Handle wildcard patterns - escape dots and replace * with .*
const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(san.value);
}
return allowedValue === san.value;
});
if (!isValidValue) {
if (hasWildcards) {
errors.push(
`${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}`
);
} else {
errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
}
}
});
}
}
if (sanPolicy.include === "prohibit" && requestSans.length > 0) {
errors.push(`${sanPolicy.type} SAN is prohibited by template policy`);
}
if (sanPolicy.include === "optional" && sanPolicy.value && sanPolicy.value.length > 0) {
const hasWildcards = sanPolicy.value.some((val) => val.includes("*"));
requestSans.forEach((san) => {
const isValidValue = sanPolicy.value!.some((allowedValue) => {
if (allowedValue.includes("*")) {
// Handle wildcard patterns - escape dots and replace * with .*
const pattern = allowedValue.replace(/\./g, "\\.").replace(/\*/g, ".*");
const regex = new RE2(`^${pattern}$`);
return regex.test(san.value);
}
return allowedValue === san.value;
});
if (!isValidValue) {
if (hasWildcards) {
errors.push(
`${sanPolicy.type} SAN value '${san.value}' does not match allowed patterns: ${sanPolicy.value!.join(", ")}`
);
} else {
errors.push(`${sanPolicy.type} SAN value '${san.value}' is not in allowed values list`);
}
}
});
}
}); });
if (request.signatureAlgorithm && template.signatureAlgorithm) { for (const [sanType, policies] of sanPoliciesByType) {
if (!template.signatureAlgorithm?.allowedAlgorithms.includes(request.signatureAlgorithm)) { const requestSans = request.subjectAlternativeNames?.filter((san) => san.type === sanType) || [];
errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
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);
}
});
}
} }
} }
if (request.keyAlgorithm && template.keyAlgorithm) { const requestSanTypes = new Set(request.subjectAlternativeNames?.map((san) => san.type) || []);
if (!template.keyAlgorithm?.allowedKeyTypes.includes(request.keyAlgorithm)) { for (const requestSanType of requestSanTypes) {
errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); if (!templateSanTypes.has(requestSanType)) {
errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`);
}
}
if (request.signatureAlgorithm) {
if (template.signatureAlgorithm && template.signatureAlgorithm.allowedAlgorithms) {
const mappedTemplateAlgorithms = template.signatureAlgorithm.allowedAlgorithms.map(
mapTemplateSignatureAlgorithmToApi
);
if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) {
errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`);
}
} else if (!template.signatureAlgorithm) {
errors.push(
`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)`
);
}
}
if (request.keyAlgorithm) {
if (template.keyAlgorithm && template.keyAlgorithm.allowedKeyTypes) {
const mappedTemplateKeyTypes = template.keyAlgorithm.allowedKeyTypes.map(mapTemplateKeyAlgorithmToApi);
if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) {
errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`);
}
} else if (!template.keyAlgorithm) {
errors.push(
`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)`
);
} }
} }
@@ -335,6 +427,44 @@ export const certificateTemplateV2ServiceFactory = ({
} }
} }
if (request.validity?.ttl && (request.notBefore || request.notAfter)) {
errors.push(
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
);
}
if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) {
errors.push("notBefore must be earlier than notAfter");
}
if ((request.notBefore || request.notAfter) && template.validity) {
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 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 < minDuration) {
errors.push(`Requested validity period (notBefore to notAfter) is below minimum required duration`);
}
}
}
}
return { return {
isValid: errors.length === 0, isValid: errors.length === 0,
errors, errors,
@@ -375,15 +505,9 @@ export const certificateTemplateV2ServiceFactory = ({
throw new Error("Template data is required"); throw new Error("Template data is required");
} }
validateTemplatePolicy({ if (data.attributes) {
attributes: data.attributes, validateSubjectAttributePolicy(data.attributes);
keyUsages: data.keyUsages, }
extendedKeyUsages: data.extendedKeyUsages,
subjectAlternativeNames: data.subjectAlternativeNames,
validity: data.validity,
signatureAlgorithm: data.signatureAlgorithm,
keyAlgorithm: data.keyAlgorithm
});
const slug = data.slug || generateTemplateSlug(); const slug = data.slug || generateTemplateSlug();
const uniqueSlug = await ensureUniqueSlug(projectId, slug); const uniqueSlug = await ensureUniqueSlug(projectId, slug);
@@ -431,18 +555,8 @@ export const certificateTemplateV2ServiceFactory = ({
ProjectPermissionSub.CertificateTemplates ProjectPermissionSub.CertificateTemplates
); );
if (hasAnyPolicyField(data)) { if (data.attributes) {
const mergedPolicy = { validateSubjectAttributePolicy(data.attributes);
attributes: data.attributes || existingTemplate.attributes,
keyUsages: data.keyUsages || existingTemplate.keyUsages,
extendedKeyUsages: data.extendedKeyUsages || existingTemplate.extendedKeyUsages,
subjectAlternativeNames: data.subjectAlternativeNames || existingTemplate.subjectAlternativeNames,
validity: data.validity || existingTemplate.validity,
signatureAlgorithm: data.signatureAlgorithm || existingTemplate.signatureAlgorithm,
keyAlgorithm: data.keyAlgorithm || existingTemplate.keyAlgorithm
};
validateTemplatePolicy(mergedPolicy);
} }
const updateData = { ...data }; const updateData = { ...data };
@@ -614,8 +728,14 @@ export const certificateTemplateV2ServiceFactory = ({
const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId);
if (isInUse) { if (isInUse) {
const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId);
const profileNames = profilesUsingTemplate.map((profile) => profile.slug || profile.id).join(", ");
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Cannot delete template that is in use by certificate profiles" 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.`
}); });
} }
@@ -1,27 +1,35 @@
import { TCertificateTemplatesV2, TCertificateTemplatesV2Insert } from "@app/db/schemas/certificate-templates-v2"; 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 { export interface TTemplateV2Policy {
attributes: Array<{ attributes: Array<{
type: "common_name"; type: CertSubjectAttributeType;
include: "mandatory" | "optional" | "prohibit"; include: CertIncludeType;
value?: string[]; value?: string[];
}>; }>;
keyUsages: { keyUsages: {
requiredUsages: { all: string[] }; requiredUsages?: { all: CertKeyUsageType[] };
optionalUsages: { all: string[] }; optionalUsages?: { all: CertKeyUsageType[] };
}; };
extendedKeyUsages: { extendedKeyUsages: {
requiredUsages: { all: string[] }; requiredUsages?: { all: CertExtendedKeyUsageType[] };
optionalUsages: { all: string[] }; optionalUsages?: { all: CertExtendedKeyUsageType[] };
}; };
subjectAlternativeNames: Array<{ subjectAlternativeNames: Array<{
type: "dns_name" | "ip_address" | "email" | "uri"; type: CertSubjectAlternativeNameType;
include: "mandatory" | "optional" | "prohibit"; include: CertIncludeType;
value?: string[]; value?: string[];
}>; }>;
validity: { validity: {
maxDuration: { value: number; unit: "days" | "months" | "years" }; maxDuration: { value: number; unit: CertDurationUnit };
minDuration?: { value: number; unit: "days" | "months" | "years" }; minDuration?: { value: number; unit: CertDurationUnit };
}; };
signatureAlgorithm: { signatureAlgorithm: {
allowedAlgorithms: string[]; allowedAlgorithms: string[];
@@ -88,15 +96,25 @@ export type TCertificateTemplateV2Update = Partial<
export interface TCertificateRequest { export interface TCertificateRequest {
commonName?: string; commonName?: string;
keyUsages?: string[]; organizationName?: string;
extendedKeyUsages?: string[]; organizationUnit?: string;
locality?: string;
state?: string;
country?: string;
email?: string;
streetAddress?: string;
postalCode?: string;
keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsageType[];
subjectAlternativeNames?: Array<{ subjectAlternativeNames?: Array<{
type: "dns_name" | "ip_address" | "email" | "uri"; type: CertSubjectAlternativeNameType;
value: string; value: string;
}>; }>;
validity?: { validity?: {
ttl: string; ttl: string;
}; };
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
} }
@@ -3,26 +3,40 @@ import z from "zod";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
export const validateTemplateRegexField = z export const createTemplateFieldValidator = (options?: {
.string() minLength?: number;
.min(1) maxLength?: number;
.max(100) allowedCharacters?: CharacterType[];
.refine( customMessage?: string;
(val) => }) => {
characterValidator([ const {
CharacterType.AlphaNumeric, minLength = 1,
CharacterType.Spaces, // (space) maxLength = 100,
CharacterType.Asterisk, // * allowedCharacters = [
CharacterType.At, // @ CharacterType.AlphaNumeric,
CharacterType.Hyphen, // - CharacterType.Spaces, // (space)
CharacterType.Period, // . CharacterType.Asterisk, // *
CharacterType.Backslash // \ CharacterType.At, // @
])(val), CharacterType.Hyphen, // -
{ CharacterType.Period, // .
message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." CharacterType.Backslash // \
} ],
) customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
// we ensure that the inputted pattern is computationally safe by limiting star height to 1 } = options || {};
.refine((v) => safe(v), {
message: "Unsafe REGEX pattern" return (
}); z
.string()
.min(minLength)
.max(maxLength)
.refine((val) => characterValidator(allowedCharacters)(val), {
message: customMessage
})
// we ensure that the inputted pattern is computationally safe by limiting star height to 1
.refine((v) => safe(v), {
message: "Unsafe REGEX pattern"
})
);
};
export const validateTemplateRegexField = createTemplateFieldValidator();
@@ -7,7 +7,13 @@ import { ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAttributeType
} from "@app/services/certificate-common/certificate-constants";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { ActorType, AuthMethod } from "../auth/auth-type"; import { ActorType, AuthMethod } from "../auth/auth-type";
@@ -76,8 +82,8 @@ describe("CertificateV3Service", () => {
describe("issueCertificateFromProfile", () => { describe("issueCertificateFromProfile", () => {
const mockCertificateRequest = { const mockCertificateRequest = {
commonName: "test.example.com", commonName: "test.example.com",
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH], extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH],
validity: { ttl: "30d" }, validity: { ttl: "30d" },
signatureAlgorithm: "RSA-SHA256", signatureAlgorithm: "RSA-SHA256",
keyAlgorithm: "RSA_2048" keyAlgorithm: "RSA_2048"
@@ -102,12 +108,19 @@ describe("CertificateV3Service", () => {
id: "template-123", id: "template-123",
signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
keyAlgorithm: { defaultKeyType: "RSA_2048" }, keyAlgorithm: { defaultKeyType: "RSA_2048" },
attributes: [] attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
}; };
const mockCertificateResult = { const mockCertificateResult = {
certificate: Buffer.from("cert"), certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"), certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("issuing-ca"),
privateKey: Buffer.from("key"), privateKey: Buffer.from("key"),
serialNumber: "123456" serialNumber: "123456"
}; };
@@ -136,6 +149,8 @@ describe("CertificateV3Service", () => {
}); });
expect(result).toHaveProperty("certificate"); expect(result).toHaveProperty("certificate");
expect(result).toHaveProperty("issuingCaCertificate");
expect(result).toHaveProperty("certificateChain");
expect(result).toHaveProperty("privateKey"); expect(result).toHaveProperty("privateKey");
expect(result).toHaveProperty("serialNumber", "123456"); expect(result).toHaveProperty("serialNumber", "123456");
expect(result).toHaveProperty("certificateId", "cert-123"); expect(result).toHaveProperty("certificateId", "cert-123");
@@ -160,12 +175,19 @@ describe("CertificateV3Service", () => {
id: "template-123", id: "template-123",
signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
keyAlgorithm: { defaultKeyType: "RSA_2048" }, keyAlgorithm: { defaultKeyType: "RSA_2048" },
attributes: [] attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
}; };
const mockCertificateResult = { const mockCertificateResult = {
certificate: Buffer.from("cert"), certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"), certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("issuing-ca"),
privateKey: Buffer.from("key"), privateKey: Buffer.from("key"),
serialNumber: "123456" serialNumber: "123456"
}; };
@@ -178,17 +200,17 @@ describe("CertificateV3Service", () => {
const camelCaseRequest = { const camelCaseRequest = {
commonName: "test.example.com", commonName: "test.example.com",
keyUsages: [ keyUsages: [
CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsageType.DIGITAL_SIGNATURE,
CertKeyUsage.NON_REPUDIATION, CertKeyUsageType.NON_REPUDIATION,
CertKeyUsage.KEY_AGREEMENT, CertKeyUsageType.KEY_AGREEMENT,
CertKeyUsage.CRL_SIGN, CertKeyUsageType.CRL_SIGN,
CertKeyUsage.DECIPHER_ONLY CertKeyUsageType.DECIPHER_ONLY
], ],
extendedKeyUsages: [ extendedKeyUsages: [
CertExtendedKeyUsage.CLIENT_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH,
CertExtendedKeyUsage.CODE_SIGNING, CertExtendedKeyUsageType.CODE_SIGNING,
CertExtendedKeyUsage.OCSP_SIGNING, CertExtendedKeyUsageType.OCSP_SIGNING,
CertExtendedKeyUsage.SERVER_AUTH CertExtendedKeyUsageType.SERVER_AUTH
], ],
validity: { ttl: "10d" } validity: { ttl: "10d" }
}; };
@@ -215,8 +237,19 @@ describe("CertificateV3Service", () => {
expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith(
"template-123", "template-123",
expect.objectContaining({ expect.objectContaining({
keyUsages: ["digital_signature", "non_repudiation", "key_agreement", "crl_sign", "decipher_only"], keyUsages: [
extendedKeyUsages: ["client_auth", "code_signing", "ocsp_signing", "server_auth"] CertKeyUsageType.DIGITAL_SIGNATURE,
CertKeyUsageType.NON_REPUDIATION,
CertKeyUsageType.KEY_AGREEMENT,
CertKeyUsageType.CRL_SIGN,
CertKeyUsageType.DECIPHER_ONLY
],
extendedKeyUsages: [
CertExtendedKeyUsageType.CLIENT_AUTH,
CertExtendedKeyUsageType.CODE_SIGNING,
CertExtendedKeyUsageType.OCSP_SIGNING,
CertExtendedKeyUsageType.SERVER_AUTH
]
}) })
); );
}); });
@@ -286,6 +319,7 @@ describe("CertificateV3Service", () => {
const mockSignResult = { const mockSignResult = {
certificate: Buffer.from("signed-cert"), certificate: Buffer.from("signed-cert"),
certificateChain: Buffer.from("chain"), certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("issuing-ca"),
serialNumber: "789012" serialNumber: "789012"
}; };
@@ -308,6 +342,8 @@ describe("CertificateV3Service", () => {
}); });
expect(result).toHaveProperty("certificate"); expect(result).toHaveProperty("certificate");
expect(result).toHaveProperty("issuingCaCertificate");
expect(result).toHaveProperty("certificateChain");
expect(result).toHaveProperty("serialNumber", "789012"); expect(result).toHaveProperty("serialNumber", "789012");
expect(result).toHaveProperty("certificateId", "cert-456"); expect(result).toHaveProperty("certificateId", "cert-456");
expect(result).not.toHaveProperty("privateKey"); expect(result).not.toHaveProperty("privateKey");
@@ -347,11 +383,11 @@ describe("CertificateV3Service", () => {
describe("orderCertificateFromProfile", () => { describe("orderCertificateFromProfile", () => {
const mockCertificateOrder = { const mockCertificateOrder = {
subjectAlternativeNames: [{ type: "dns" as const, value: "example.com" }], altNames: [{ type: ACMESANType.DNS, value: "example.com" }],
validity: { ttl: "30d" }, validity: { ttl: "30d" },
commonName: "example.com", commonName: "example.com",
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE], keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH], extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH],
signatureAlgorithm: "RSA-SHA256", signatureAlgorithm: "RSA-SHA256",
keyAlgorithm: "RSA_2048" keyAlgorithm: "RSA_2048"
}; };
@@ -375,12 +411,19 @@ describe("CertificateV3Service", () => {
id: "template-123", id: "template-123",
signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
keyAlgorithm: { defaultKeyType: "RSA_2048" }, keyAlgorithm: { defaultKeyType: "RSA_2048" },
attributes: [] attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
}; };
const mockCertificateResult = { const mockCertificateResult = {
certificate: Buffer.from("cert"), certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"), certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("issuing-ca"),
privateKey: Buffer.from("key"), privateKey: Buffer.from("key"),
serialNumber: "123456" serialNumber: "123456"
}; };
@@ -413,9 +456,9 @@ describe("CertificateV3Service", () => {
expect(result).toHaveProperty("certificate"); expect(result).toHaveProperty("certificate");
expect(result.subjectAlternativeNames).toHaveLength(1); expect(result.subjectAlternativeNames).toHaveLength(1);
expect(result.subjectAlternativeNames[0]).toEqual({ expect(result.subjectAlternativeNames[0]).toEqual({
type: "dns", type: ACMESANType.DNS,
value: "example.com", value: "example.com",
status: "valid" status: CertificateOrderStatus.VALID
}); });
}); });
@@ -448,4 +491,230 @@ describe("CertificateV3Service", () => {
).rejects.toThrow("Profile is not configured for api enrollment"); ).rejects.toThrow("Profile is not configured for api enrollment");
}); });
}); });
describe("algorithm compatibility (integration tests)", () => {
const mockProfile = {
id: "profile-1",
slug: "test-profile",
projectId: "project-1",
caId: "ca-1",
certificateTemplateId: "template-1",
enrollmentType: EnrollmentType.API
};
const mockCertificateRequest = {
commonName: "test.example.com",
validity: { ttl: "30d" },
keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH]
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should successfully process RSA algorithms with RSA CAs", async () => {
const rsaCa = {
id: "ca-1",
projectId: "project-1",
status: "active",
internalCa: {
keyAlgorithm: "RSA_2048"
}
};
const rsaTemplate = {
id: "template-1",
signatureAlgorithm: {
allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"]
},
attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
};
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(rsaCa);
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] });
mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(rsaTemplate);
mockInternalCaService.issueCertFromCa.mockResolvedValue({
certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("ca-cert"),
privateKey: Buffer.from("key"),
serialNumber: "123456"
});
mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" });
mockCertificateDAL.updateById.mockResolvedValue(undefined);
// Should not throw - RSA CA is compatible with RSA signature algorithms
await expect(
service.issueCertificateFromProfile({
profileId: mockProfile.id,
certificateRequest: {
...mockCertificateRequest,
signatureAlgorithm: "RSA-SHA256"
},
...mockActor
})
).resolves.toBeDefined();
});
it("should successfully process ECDSA algorithms with EC CAs", async () => {
const ecCa = {
id: "ca-1",
projectId: "project-1",
status: "active",
internalCa: {
keyAlgorithm: "EC_prime256v1"
}
};
const ecdsaTemplate = {
id: "template-1",
signatureAlgorithm: {
allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"]
},
attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
};
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(ecCa);
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] });
mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(ecdsaTemplate);
mockInternalCaService.issueCertFromCa.mockResolvedValue({
certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("ca-cert"),
privateKey: Buffer.from("key"),
serialNumber: "123456"
});
mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" });
mockCertificateDAL.updateById.mockResolvedValue(undefined);
// Should not throw - EC CA is compatible with ECDSA signature algorithms
await expect(
service.issueCertificateFromProfile({
profileId: mockProfile.id,
certificateRequest: {
...mockCertificateRequest,
signatureAlgorithm: "ECDSA-SHA256"
},
...mockActor
})
).resolves.toBeDefined();
});
it("should dynamically support new RSA key sizes", async () => {
const rsa8192Ca = {
id: "ca-1",
projectId: "project-1",
status: "active",
internalCa: {
keyAlgorithm: "RSA_8192" // Future RSA key size
}
};
const rsaTemplate = {
id: "template-1",
signatureAlgorithm: {
allowedAlgorithms: ["SHA256-RSA"]
},
attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
};
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(rsa8192Ca);
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] });
mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(rsaTemplate);
mockInternalCaService.issueCertFromCa.mockResolvedValue({
certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("ca-cert"),
privateKey: Buffer.from("key"),
serialNumber: "123456"
});
mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" });
mockCertificateDAL.updateById.mockResolvedValue(undefined);
// Should not throw - dynamic check supports new RSA key sizes
await expect(
service.issueCertificateFromProfile({
profileId: mockProfile.id,
certificateRequest: {
...mockCertificateRequest,
signatureAlgorithm: "RSA-SHA256"
},
...mockActor
})
).resolves.toBeDefined();
});
it("should dynamically support new EC curve types", async () => {
const newEcCa = {
id: "ca-1",
projectId: "project-1",
status: "active",
internalCa: {
keyAlgorithm: "EC_secp521r1" // Future EC curve
}
};
const ecdsaTemplate = {
id: "template-1",
signatureAlgorithm: {
allowedAlgorithms: ["SHA384-ECDSA"]
},
attributes: [
{
type: CertSubjectAttributeType.COMMON_NAME,
include: CertIncludeType.OPTIONAL,
value: ["example.com"]
}
]
};
mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile);
mockCertificateAuthorityDAL.findByIdWithAssociatedCa.mockResolvedValue(newEcCa);
mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ isValid: true, errors: [] });
mockCertificateTemplateV2Service.getTemplateV2ById.mockResolvedValue(ecdsaTemplate);
mockInternalCaService.issueCertFromCa.mockResolvedValue({
certificate: Buffer.from("cert"),
certificateChain: Buffer.from("chain"),
issuingCaCertificate: Buffer.from("ca-cert"),
privateKey: Buffer.from("key"),
serialNumber: "123456"
});
mockCertificateDAL.findOne.mockResolvedValue({ id: "cert-1" });
mockCertificateDAL.updateById.mockResolvedValue(undefined);
// Should not throw - dynamic check supports new EC curves
await expect(
service.issueCertificateFromProfile({
profileId: mockProfile.id,
certificateRequest: {
...mockCertificateRequest,
signatureAlgorithm: "ECDSA-SHA384"
},
...mockActor
})
).resolves.toBeDefined();
});
});
}); });
@@ -10,6 +10,7 @@ import {
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { CertificateOrderStatus } from "@app/services/certificate/certificate-types";
import { import {
TCertificateAuthorityDALFactory, TCertificateAuthorityDALFactory,
TCertificateAuthorityWithAssociatedCa TCertificateAuthorityWithAssociatedCa
@@ -20,10 +21,13 @@ import { TCertificateProfileDALFactory } from "@app/services/certificate-profile
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants";
import { import {
bufferToString, bufferToString,
buildCertificateSubjectFromTemplate, buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate, buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayToLegacy,
mapEnumsForValidation, mapEnumsForValidation,
normalizeDateForApi normalizeDateForApi
} from "../certificate-common/certificate-utils"; } from "../certificate-common/certificate-utils";
@@ -95,6 +99,45 @@ const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation:
return caType; return caType;
}; };
const validateAlgorithmCompatibility = (
ca: TCertificateAuthorityWithAssociatedCa,
template: {
signatureAlgorithm?: {
allowedAlgorithms?: string[];
};
}
) => {
if (!template.signatureAlgorithm || !template.signatureAlgorithm.allowedAlgorithms) {
return;
}
const caKeyAlgorithm = ca.internalCa?.keyAlgorithm;
if (!caKeyAlgorithm) {
throw new BadRequestError({ message: "CA key algorithm not found" });
}
const compatibleAlgorithms = template.signatureAlgorithm.allowedAlgorithms.filter((sigAlg: string) => {
const parts = sigAlg.split("-");
const keyType = parts[parts.length - 1];
if (caKeyAlgorithm.startsWith("RSA")) {
return keyType === "RSA";
}
if (caKeyAlgorithm.startsWith("EC")) {
return keyType === "ECDSA";
}
return false;
});
if (compatibleAlgorithms.length === 0) {
throw new BadRequestError({
message: `Template signature algorithms (${template.signatureAlgorithm.allowedAlgorithms.join(", ")}) are not compatible with CA key algorithm (${caKeyAlgorithm})`
});
}
};
const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => { const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => {
if (typeof certData === "string") return certData; if (typeof certData === "string") return certData;
if (Buffer.isBuffer(certData)) return bufferToString(certData); if (Buffer.isBuffer(certData)) return bufferToString(certData);
@@ -131,6 +174,12 @@ export const certificateV3ServiceFactory = ({
EnrollmentType.API EnrollmentType.API
); );
if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) {
throw new BadRequestError({
message: "Common Name must be a single value, not an array"
});
}
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const validationResult = await certificateTemplateV2Service.validateCertificateRequest( const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId, profile.certificateTemplateId,
@@ -166,33 +215,48 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate template not found for this profile" }); throw new NotFoundError({ message: "Certificate template not found for this profile" });
} }
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = const effectiveSignatureAlgorithm =
certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm; certificateRequest.signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
});
}
const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes); const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.attributes);
const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate(
certificateRequest, { subjectAlternativeNames: certificateRequest.altNames },
template.subjectAlternativeNames template.subjectAlternativeNames
); );
const { certificate, certificateChain, privateKey, serialNumber } = await internalCaService.issueCertFromCa({ const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
caId: ca.id, await internalCaService.issueCertFromCa({
friendlyName: certificateSubject.common_name || "Certificate", caId: ca.id,
commonName: certificateSubject.common_name || "", friendlyName: certificateSubject.common_name || "Certificate",
altNames: subjectAlternativeNames, commonName: certificateSubject.common_name || "",
ttl: certificateRequest.validity.ttl, altNames: subjectAlternativeNames,
keyUsages: certificateRequest.keyUsages, ttl: certificateRequest.validity.ttl,
extendedKeyUsages: certificateRequest.extendedKeyUsages, keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [],
notBefore: normalizeDateForApi(certificateRequest.notBefore), extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [],
notAfter: normalizeDateForApi(certificateRequest.notAfter), notBefore: normalizeDateForApi(certificateRequest.notBefore),
signatureAlgorithm: effectiveSignatureAlgorithm, notAfter: normalizeDateForApi(certificateRequest.notAfter),
keyAlgorithm: effectiveKeyAlgorithm, signatureAlgorithm: effectiveSignatureAlgorithm,
actor, keyAlgorithm: effectiveKeyAlgorithm,
actorId, actor,
actorAuthMethod, actorId,
actorOrgId actorAuthMethod,
}); actorOrgId
});
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
if (!cert) { if (!cert) {
@@ -201,14 +265,15 @@ export const certificateV3ServiceFactory = ({
await certificateDAL.updateById(cert.id, { profileId }); await certificateDAL.updateById(cert.id, { profileId });
const certificateChainString = bufferToString(certificateChain);
return { return {
certificate: bufferToString(certificate), certificate: bufferToString(certificate),
issuingCaCertificate: certificateChainString.split("\n").pop() || bufferToString(certificate), issuingCaCertificate: bufferToString(issuingCaCertificate),
certificateChain: certificateChainString, certificateChain: bufferToString(certificateChain),
privateKey: bufferToString(privateKey), privateKey: bufferToString(privateKey),
serialNumber, serialNumber,
certificateId: cert.id certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
}; };
}; };
@@ -218,6 +283,8 @@ export const certificateV3ServiceFactory = ({
validity, validity,
notBefore, notBefore,
notAfter, notAfter,
signatureAlgorithm,
keyAlgorithm,
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
@@ -241,16 +308,52 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "CSR signing"); validateCaSupport(ca, "CSR signing");
const { certificate, certificateChain, serialNumber } = await internalCaService.signCertFromCa({ if (!actorAuthMethod) {
isInternal: true, throw new BadRequestError({ message: "Authentication method is required for certificate signing" });
caId: ca.id, }
csr,
ttl: validity.ttl, const template = await certificateTemplateV2Service.getTemplateV2ById({
altNames: "", actor,
notBefore: normalizeDateForApi(notBefore), actorId,
notAfter: normalizeDateForApi(notAfter) actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId
}); });
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
}
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm || template.signatureAlgorithm?.defaultAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm || template.keyAlgorithm?.defaultKeyType;
if (template.keyAlgorithm?.allowedKeyTypes && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request or template default"
});
}
if (template.signatureAlgorithm?.allowedAlgorithms && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request or template default"
});
}
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
await internalCaService.signCertFromCa({
isInternal: true,
caId: ca.id,
csr,
ttl: validity.ttl,
altNames: "",
notBefore: normalizeDateForApi(notBefore),
notAfter: normalizeDateForApi(notAfter),
signatureAlgorithm: effectiveSignatureAlgorithm,
keyAlgorithm: effectiveKeyAlgorithm
});
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
if (!cert) { if (!cert) {
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
@@ -263,10 +366,12 @@ export const certificateV3ServiceFactory = ({
return { return {
certificate: certificateString, certificate: certificateString,
issuingCaCertificate: certificateChainString.split("\n").pop() || certificateString, issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer),
certificateChain: certificateChainString, certificateChain: certificateChainString,
serialNumber, serialNumber,
certificateId: cert.id certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
}; };
}; };
@@ -293,8 +398,8 @@ export const certificateV3ServiceFactory = ({
commonName: certificateOrder.commonName, commonName: certificateOrder.commonName,
keyUsages: certificateOrder.keyUsages, keyUsages: certificateOrder.keyUsages,
extendedKeyUsages: certificateOrder.extendedKeyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages,
subjectAlternativeNames: certificateOrder.subjectAlternativeNames.map((san) => ({ subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
type: san.type === "dns" ? ("dns_name" as const) : ("ip_address" as const), type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS,
value: san.value value: san.value
})), })),
validity: certificateOrder.validity, validity: certificateOrder.validity,
@@ -334,43 +439,26 @@ export const certificateV3ServiceFactory = ({
}); });
const orderId = randomUUID(); const orderId = randomUUID();
const subjectAlternativeNames = certificateOrder.subjectAlternativeNames.map((san) => ({
type: san.type,
value: san.value,
status: "valid" as const
}));
const authorizations = certificateOrder.subjectAlternativeNames.map((san) => ({
identifier: {
type: san.type,
value: san.value
},
status: "valid" as const,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
challenges: [
{
type: "internal-validation",
status: "valid" as const,
url: `/api/v3/certificates/orders/${orderId}/internal`,
token: "internal-ca-validation"
}
]
}));
return { return {
orderId, orderId,
status: "valid", status: CertificateOrderStatus.VALID,
subjectAlternativeNames, subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
authorizations, type: san.type,
finalize: `/api/v3/certificates/orders/${orderId}/finalize`, value: san.value,
certificate: certificateResult.certificate status: CertificateOrderStatus.VALID
})),
authorizations: [],
finalize: `/api/v3/certificates/orders/${orderId}/completed`,
certificate: certificateResult.certificate,
projectId: certificateResult.projectId,
profileName: certificateResult.profileName
}; };
} }
if (caType === CaType.ACME) { if (caType === CaType.ACME) {
throw new BadRequestError({ throw new BadRequestError({
message: message: "ACME certificate ordering via profiles is not yet implemented."
"ACME certificate ordering via profiles is not yet implemented. Use direct certificate issuance for ACME CAs."
}); });
} }
@@ -1,15 +1,20 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType
} from "../certificate-common/certificate-constants";
export type TIssueCertificateFromProfileDTO = { export type TIssueCertificateFromProfileDTO = {
profileId: string; profileId: string;
certificateRequest: { certificateRequest: {
commonName?: string; commonName?: string;
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsageType[];
subjectAlternativeNames?: Array<{ altNames?: Array<{
type: "dns_name" | "ip_address" | "email" | "uri"; type: CertSubjectAlternativeNameType;
value: string; value: string;
}>; }>;
validity: { validity: {
@@ -30,21 +35,23 @@ export type TSignCertificateFromProfileDTO = {
}; };
notBefore?: Date; notBefore?: Date;
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TOrderCertificateFromProfileDTO = { export type TOrderCertificateFromProfileDTO = {
profileId: string; profileId: string;
certificateOrder: { certificateOrder: {
subjectAlternativeNames: Array<{ altNames: Array<{
type: "dns" | "ip"; type: ACMESANType;
value: string; value: string;
}>; }>;
validity: { validity: {
ttl: string; ttl: string;
}; };
commonName?: string; commonName?: string;
keyUsages?: CertKeyUsage[]; keyUsages?: CertKeyUsageType[];
extendedKeyUsages?: CertExtendedKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsageType[];
notBefore?: Date; notBefore?: Date;
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string; signatureAlgorithm?: string;
@@ -59,30 +66,34 @@ export type TCertificateFromProfileResponse = {
privateKey?: string; privateKey?: string;
serialNumber: string; serialNumber: string;
certificateId: string; certificateId: string;
projectId: string;
profileName: string;
}; };
export type TCertificateOrderResponse = { export type TCertificateOrderResponse = {
orderId: string; orderId: string;
status: "pending" | "processing" | "valid" | "invalid"; status: CertificateOrderStatus;
subjectAlternativeNames: Array<{ subjectAlternativeNames: Array<{
type: "dns" | "ip"; type: ACMESANType;
value: string; value: string;
status: "pending" | "processing" | "valid" | "invalid"; status: CertificateOrderStatus;
}>; }>;
authorizations: Array<{ authorizations: Array<{
identifier: { identifier: {
type: "dns" | "ip"; type: ACMESANType;
value: string; value: string;
}; };
status: "pending" | "processing" | "valid" | "invalid"; status: CertificateOrderStatus;
expires?: string; expires?: string;
challenges: Array<{ challenges: Array<{
type: string; type: string;
status: "pending" | "processing" | "valid" | "invalid"; status: CertificateOrderStatus;
url: string; url: string;
token: string; token: string;
}>; }>;
}>; }>;
finalize: string; finalize: string;
certificate?: string; certificate?: string;
projectId: string;
profileName: string;
}; };
@@ -18,6 +18,15 @@ export enum CertKeyAlgorithm {
ECDSA_P384 = "EC_secp384r1" ECDSA_P384 = "EC_secp384r1"
} }
export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384",
RSA_SHA512 = "RSA-SHA512",
ECDSA_SHA256 = "ECDSA-SHA256",
ECDSA_SHA384 = "ECDSA-SHA384",
ECDSA_SHA512 = "ECDSA-SHA512"
}
export enum CertKeyUsage { export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature", DIGITAL_SIGNATURE = "digitalSignature",
KEY_ENCIPHERMENT = "keyEncipherment", KEY_ENCIPHERMENT = "keyEncipherment",
@@ -111,7 +120,26 @@ export enum TAltNameType {
IP = "ip", IP = "ip",
URL = "url" URL = "url"
} }
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export type TAltNameMapping = { export type TAltNameMapping = {
type: TAltNameType; type: TAltNameType;
value: string; value: string;
}; };
export enum ACMESANType {
DNS = "dns",
IP = "ip"
}
export enum CertificateOrderStatus {
PENDING = "pending",
PROCESSING = "processing",
VALID = "valid",
INVALID = "invalid"
}
@@ -32,16 +32,6 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
} }
}; };
const deleteById = async (id: string, tx?: Knex) => {
try {
const [estConfig] = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).del().returning("*");
return estConfig;
} catch (error) {
throw new DatabaseError({ error, name: "Delete EST enrollment config" });
}
};
const findById = async (id: string, tx?: Knex) => { const findById = async (id: string, tx?: Knex) => {
try { try {
const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first();
@@ -52,25 +42,10 @@ export const estEnrollmentConfigDALFactory = (db: TDbClient) => {
} }
}; };
const isConfigInUse = async (configId: string, tx?: Knex) => {
try {
const profileCount = await (tx || db)(TableName.CertificateProfile)
.where({ estConfigId: configId })
.count("* as count")
.first();
return parseInt(profileCount || "0", 10) > 0;
} catch (error) {
throw new DatabaseError({ error, name: "Check if EST enrollment config is in use" });
}
};
return { return {
...estEnrollmentConfigOrm, ...estEnrollmentConfigOrm,
create, create,
updateById, updateById,
deleteById, findById
findById,
isConfigInUse
}; };
}; };
@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/pki/certificate-profiles"
---
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/pki/certificate-profiles/{id}"
---
@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/pki/certificate-profiles/{id}"
---
@@ -0,0 +1,4 @@
---
title: "Get by Slug"
openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}"
---
@@ -0,0 +1,4 @@
---
title: "List Certificates"
openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/pki/certificate-profiles"
---
@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/pki/certificate-profiles/{id}"
---
@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v2/certificate-templates"
---
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v2/certificate-templates/{id}"
---
@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v2/certificate-templates/{id}"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/certificate-templates"
---
@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v2/certificate-templates/{id}"
---
@@ -2,3 +2,9 @@
title: "Create" title: "Create"
openapi: "POST /api/v1/pki/certificate-templates" openapi: "POST /api/v1/pki/certificate-templates"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Delete" title: "Delete"
openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Get by ID" title: "Get by ID"
openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Update" title: "Update"
openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Templates V2 API](/api-reference/endpoints/certificate-templates-v2) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Create" title: "Create"
openapi: "POST /api/v1/pki/subscribers" openapi: "POST /api/v1/pki/subscribers"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Delete" title: "Delete"
openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Retrieve latest certificate bundle" title: "Retrieve latest certificate bundle"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Issue Certificate" title: "Issue Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "List Certificates" title: "List Certificates"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Order Certificate" title: "Order Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Retrieve" title: "Retrieve"
openapi: "GET /api/v1/pki/subscribers/{subscriberName}" openapi: "GET /api/v1/pki/subscribers/{subscriberName}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Sign Certificate" title: "Sign Certificate"
openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
@@ -2,3 +2,9 @@
title: "Update" title: "Update"
openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}"
--- ---
<Warning>
**Deprecated API Endpoint**
This endpoint is deprecated and will be removed in a future version. Please use the new [Certificate Profiles API](/api-reference/endpoints/certificate-profiles) instead.
</Warning>
+24 -4
View File
@@ -2493,10 +2493,21 @@
{ {
"group": "Certificate Templates", "group": "Certificate Templates",
"pages": [ "pages": [
"api-reference/endpoints/certificate-templates/create", "api-reference/endpoints/certificate-templates-v2/list",
"api-reference/endpoints/certificate-templates/update", "api-reference/endpoints/certificate-templates-v2/create",
"api-reference/endpoints/certificate-templates/get-by-id", "api-reference/endpoints/certificate-templates-v2/update",
"api-reference/endpoints/certificate-templates/delete" "api-reference/endpoints/certificate-templates-v2/get-by-id",
"api-reference/endpoints/certificate-templates-v2/delete",
{
"group": "Legacy",
"pages": [
"api-reference/endpoints/certificate-templates/list",
"api-reference/endpoints/certificate-templates/create",
"api-reference/endpoints/certificate-templates/update",
"api-reference/endpoints/certificate-templates/get-by-id",
"api-reference/endpoints/certificate-templates/delete"
]
}
] ]
}, },
{ {
@@ -2520,6 +2531,15 @@
"api-reference/endpoints/pki-alerts/delete" "api-reference/endpoints/pki-alerts/delete"
] ]
}, },
{
"group": "Certificate Profiles",
"pages": [
"api-reference/endpoints/certificate-profiles/create",
"api-reference/endpoints/certificate-profiles/update",
"api-reference/endpoints/certificate-profiles/get-by-id",
"api-reference/endpoints/certificate-profiles/delete"
]
},
{ {
"group": "Certificate Syncs", "group": "Certificate Syncs",
"pages": [ "pages": [
@@ -4,6 +4,7 @@ export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionAuditLogsActions, ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions, ProjectPermissionGroupActions,
@@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions {
ListCerts = "list-certs" ListCerts = "list-certs"
} }
export enum ProjectPermissionCertificateProfileActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
}
export enum ProjectPermissionSecretRotationActions { export enum ProjectPermissionSecretRotationActions {
Read = "read", Read = "read",
ReadGeneratedCredentials = "read-generated-credentials", ReadGeneratedCredentials = "read-generated-credentials",
@@ -217,6 +225,7 @@ export type ConditionalProjectPermissionSubject =
| ProjectPermissionSub.SshHosts | ProjectPermissionSub.SshHosts
| ProjectPermissionSub.PkiSubscribers | ProjectPermissionSub.PkiSubscribers
| ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.CertificateTemplates
| ProjectPermissionSub.CertificateProfiles
| ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretImports
| ProjectPermissionSub.SecretRotation | ProjectPermissionSub.SecretRotation
@@ -293,6 +302,7 @@ export enum ProjectPermissionSub {
PkiAlerts = "pki-alerts", PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections", PkiCollections = "pki-collections",
PkiSubscribers = "pki-subscribers", PkiSubscribers = "pki-subscribers",
CertificateProfiles = "certificate-profiles",
Kms = "kms", Kms = "kms",
Cmek = "cmek", Cmek = "cmek",
SecretSyncs = "secret-syncs", SecretSyncs = "secret-syncs",
@@ -470,6 +480,7 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.PkiSubscribers> & PkiSubscriberSubjectFields) | (ForcedSubject<ProjectPermissionSub.PkiSubscribers> & PkiSubscriberSubjectFields)
) )
] ]
| [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
+1
View File
@@ -15,6 +15,7 @@ export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionAuditLogsActions, ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionGroupActions, ProjectPermissionGroupActions,
+1 -1
View File
@@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[]
case ProjectType.SecretManager: case ProjectType.SecretManager:
return "/projects/secret-management/$projectId/overview" as const; return "/projects/secret-management/$projectId/overview" as const;
case ProjectType.CertificateManager: case ProjectType.CertificateManager:
return "/projects/cert-management/$projectId/subscribers" as const; return "/projects/cert-management/$projectId/policies" as const;
case ProjectType.SecretScanning: case ProjectType.SecretScanning:
return `/projects/${type}/$projectId/data-sources` as const; return `/projects/${type}/$projectId/data-sources` as const;
case ProjectType.PAM: case ProjectType.PAM:
+2
View File
@@ -1,10 +1,12 @@
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types";
export { export {
useCreateCa, useCreateCa,
useCreateCertificate, useCreateCertificate,
useCreateCertificateV3, useCreateCertificateV3,
useDeleteCa, useDeleteCa,
useImportCaCertificate, useImportCaCertificate,
useOrderCertificateWithProfile,
useRenewCa, useRenewCa,
useSignIntermediate, useSignIntermediate,
useUpdateCa useUpdateCa
+20
View File
@@ -14,6 +14,8 @@ import {
TDeleteCertificateAuthorityDTO, TDeleteCertificateAuthorityDTO,
TImportCaCertificateDTO, TImportCaCertificateDTO,
TImportCaCertificateResponse, TImportCaCertificateResponse,
TOrderCertificateDTO,
TOrderCertificateResponse,
TRenewCaDTO, TRenewCaDTO,
TRenewCaResponse, TRenewCaResponse,
TSignIntermediateDTO, TSignIntermediateDTO,
@@ -168,6 +170,24 @@ export const useCreateCertificateV3 = () => {
}); });
}; };
export const useOrderCertificateWithProfile = () => {
const queryClient = useQueryClient();
return useMutation<TOrderCertificateResponse, object, TOrderCertificateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TOrderCertificateResponse>(
"/api/v3/certificates/order-certificate",
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};
export const useRenewCa = () => { export const useRenewCa = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TRenewCaResponse, object, TRenewCaDTO>({ return useMutation<TRenewCaResponse, object, TRenewCaDTO>({
+50 -2
View File
@@ -155,7 +155,7 @@ export type TCreateCertificateDTO = {
pkiCollectionId?: string; pkiCollectionId?: string;
friendlyName?: string; friendlyName?: string;
commonName: string; commonName: string;
altNames: string; // sans subjectAltNames: string; // sans
ttl: string; // string compatible with ms ttl: string; // string compatible with ms
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
@@ -185,7 +185,7 @@ export type TCreateCertificateV3DTO = {
email?: string; email?: string;
streetAddress?: string; streetAddress?: string;
postalCode?: string; postalCode?: string;
altNames: string; subjectAltNames: string;
ttl: string; ttl: string;
notBefore?: string; notBefore?: string;
notAfter?: string; notAfter?: string;
@@ -197,6 +197,54 @@ export type TCreateCertificateV3DTO = {
export type TCreateCertificateV3Response = TCreateCertificateResponse; export type TCreateCertificateV3Response = TCreateCertificateResponse;
export type TOrderCertificateDTO = {
projectSlug: string;
profileId: string;
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
}>;
ttl: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
notBefore?: string;
notAfter?: string;
commonName?: string;
signatureAlgorithm?: string;
keyAlgorithm?: string;
};
export type TOrderCertificateResponse = {
orderId: string;
status: "pending" | "processing" | "valid" | "invalid";
subjectAlternativeNames: Array<{
type: "dns" | "ip";
value: string;
status: "pending" | "processing" | "valid" | "invalid";
}>;
authorizations: Array<{
identifier: {
type: "dns" | "ip";
value: string;
};
status: "pending" | "processing" | "valid" | "invalid";
expires?: string;
challenges: Array<{
type: string;
status: "pending" | "processing" | "valid" | "invalid";
url: string;
token: string;
validated?: string;
error?: any;
}>;
}>;
certificate?: string;
privateKey?: string;
expires: string;
notBefore: string;
notAfter: string;
};
export type TRenewCaDTO = { export type TRenewCaDTO = {
projectSlug: string; projectSlug: string;
caId: string; caId: string;
@@ -124,16 +124,7 @@ export type TListCertificateTemplatesDTO = {
export type TCertificateTemplateV2Policy = { export type TCertificateTemplateV2Policy = {
attributes: Array<{ attributes: Array<{
type: type: "common_name";
| "common_name"
| "organization_name"
| "organization_unit"
| "locality"
| "state"
| "country"
| "email"
| "street_address"
| "postal_code";
include: "mandatory" | "optional" | "prohibit"; include: "mandatory" | "optional" | "prohibit";
value?: string[]; value?: string[];
}>; }>;
+15 -15
View File
@@ -25,22 +25,22 @@ export enum CrlReason {
} }
export enum CertKeyUsage { export enum CertKeyUsage {
DIGITAL_SIGNATURE = "digitalSignature", DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "keyEncipherment", KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "nonRepudiation", NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "dataEncipherment", DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "keyAgreement", KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "keyCertSign", KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "cRLSign", CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipherOnly", ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipherOnly" DECIPHER_ONLY = "decipher_only"
} }
export enum CertExtendedKeyUsage { export enum CertExtendedKeyUsage {
CLIENT_AUTH = "clientAuth", CLIENT_AUTH = "client_auth",
SERVER_AUTH = "serverAuth", SERVER_AUTH = "server_auth",
CODE_SIGNING = "codeSigning", CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "emailProtection", EMAIL_PROTECTION = "email_protection",
TIMESTAMPING = "timeStamping", TIMESTAMPING = "time_stamping",
OCSP_SIGNING = "ocspSigning" OCSP_SIGNING = "ocsp_signing"
} }
+1 -1
View File
@@ -7,7 +7,7 @@ export type TCertificate = {
status: CertStatus; status: CertStatus;
friendlyName: string; friendlyName: string;
commonName: string; commonName: string;
altNames: string; subjectAltNames: string;
serialNumber: string; serialNumber: string;
notBefore: string; notBefore: string;
notAfter: string; notAfter: string;
@@ -18,7 +18,7 @@ import { Link, Outlet } from "@tanstack/react-router";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2";
import { useProject, useProjectPermission } from "@app/context"; import { useProject, useProjectPermission, useSubscription } from "@app/context";
import { import {
useListWorkspaceCertificateTemplates, useListWorkspaceCertificateTemplates,
useListWorkspacePkiSubscribers useListWorkspacePkiSubscribers
@@ -29,6 +29,7 @@ import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePri
export const PkiManagerLayout = () => { export const PkiManagerLayout = () => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const { assumedPrivilegeDetails } = useProjectPermission(); const { assumedPrivilegeDetails } = useProjectPermission();
const { subscription } = useSubscription();
const { t } = useTranslation(); const { t } = useTranslation();
const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || "");
@@ -39,7 +40,8 @@ export const PkiManagerLayout = () => {
const hasExistingSubscribers = subscribers.length > 0; const hasExistingSubscribers = subscribers.length > 0;
const hasExistingTemplates = templates.length > 0; const hasExistingTemplates = templates.length > 0;
const showLegacySection = hasExistingSubscribers || hasExistingTemplates; const showLegacySection =
subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates;
return ( return (
<> <>
@@ -166,7 +168,7 @@ export const PkiManagerLayout = () => {
</MenuGroup> </MenuGroup>
{showLegacySection && ( {showLegacySection && (
<MenuGroup title="Legacy"> <MenuGroup title="Legacy">
{hasExistingSubscribers && ( {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && (
<Link <Link
to="/projects/cert-management/$projectId/subscribers" to="/projects/cert-management/$projectId/subscribers"
params={{ params={{
@@ -185,7 +187,7 @@ export const PkiManagerLayout = () => {
)} )}
</Link> </Link>
)} )}
{hasExistingTemplates && ( {(subscription.pkiLegacyTemplates || hasExistingTemplates) && (
<Link <Link
to="/projects/cert-management/$projectId/certificate-templates" to="/projects/cert-management/$projectId/certificate-templates"
params={{ params={{
@@ -7,7 +7,6 @@ import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
FormControl, FormControl,
Input,
Modal, Modal,
ModalContent, ModalContent,
Select, Select,
@@ -24,8 +23,6 @@ const schema = z.object({
certificatePem: z.string().trim().min(1, "Certificate PEM is required"), certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"), privateKeyPem: z.string().trim().min(1, "Private Key PEM is required"),
chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"), chainPem: z.string().trim().min(1, "Certificate Chain PEM is required"),
friendlyName: z.string(),
collectionId: z.string().optional() collectionId: z.string().optional()
}); });
@@ -72,7 +69,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem, certificatePem,
privateKeyPem, privateKeyPem,
chainPem, chainPem,
friendlyName,
collectionId collectionId
}: FormData) => { }: FormData) => {
try { try {
@@ -84,8 +80,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
certificatePem, certificatePem,
privateKeyPem, privateKeyPem,
chainPem, chainPem,
friendlyName,
pkiCollectionId: collectionId pkiCollectionId: collectionId
}); });
@@ -150,20 +144,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue=""
@@ -46,9 +46,8 @@ const schema = z.object({
certificateTemplateId: z.string().optional(), certificateTemplateId: z.string().optional(),
caId: z.string(), caId: z.string(),
collectionId: z.string().optional(), collectionId: z.string().optional(),
friendlyName: z.string(),
commonName: z.string().trim().min(1), commonName: z.string().trim().min(1),
altNames: z.string(), subjectAltNames: z.string(),
ttl: z.string().trim(), ttl: z.string().trim(),
keyUsages: z.object({ keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
@@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
if (cert) { if (cert) {
reset({ reset({
caId: cert.caId, caId: cert.caId,
friendlyName: cert.friendlyName,
commonName: cert.commonName, commonName: cert.commonName,
altNames: cert.altNames, subjectAltNames: cert.subjectAltNames,
certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE,
ttl: "", ttl: "",
keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])),
@@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
} else { } else {
reset({ reset({
caId: "", caId: "",
friendlyName: "",
commonName: "", commonName: "",
altNames: "", subjectAltNames: "",
ttl: "", ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE, certificateTemplateId: CERT_TEMPLATE_NONE_VALUE,
keyUsages: { keyUsages: {
@@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({ const onFormSubmit = async ({
caId, caId,
friendlyName,
collectionId, collectionId,
commonName, commonName,
altNames, subjectAltNames,
ttl, ttl,
keyUsages, keyUsages,
extendedKeyUsages extendedKeyUsages
@@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentProject.slug, projectSlug: currentProject.slug,
pkiCollectionId: collectionId, pkiCollectionId: collectionId,
friendlyName,
commonName, commonName,
altNames, subjectAltNames,
ttl, ttl,
keyUsages: Object.entries(keyUsages) keyUsages: Object.entries(keyUsages)
.filter(([, value]) => value) .filter(([, value]) => value)
@@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
/> />
</> </>
)} )}
<Controller
control={control}
defaultValue=""
name="friendlyName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Friendly Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue=""
@@ -391,7 +372,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue=""
name="altNames" name="subjectAltNames"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Alternative Names (SANs)" label="Alternative Names (SANs)"
@@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2";
import { import {
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProject useProject,
useSubscription
} from "@app/context"; } from "@app/context";
import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api"; import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api";
import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types"; import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types";
@@ -18,9 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable";
export const PkiSubscriberSection = () => { export const PkiSubscriberSection = () => {
const { currentProject } = useProject(); const { currentProject } = useProject();
const { subscription } = useSubscription();
const projectId = currentProject.id; const projectId = currentProject.id;
const allowNewSubscriberCreation = false; const allowNewSubscriberCreation = subscription.pkiLegacyTemplates;
const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber();
const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber();
@@ -85,16 +87,16 @@ export const PkiSubscriberSection = () => {
const subscriberName = subscriberStatusData?.subscriberName || ""; const subscriberName = subscriberStatusData?.subscriberName || "";
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="border-mineshaft-600 bg-mineshaft-900 mb-6 rounded-lg border p-4">
<div className="mb-4 flex justify-between"> <div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Subscribers</p> <p className="text-mineshaft-100 text-xl font-medium">Subscribers</p>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<a <a
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/pki/subscribers" href="https://infisical.com/docs/documentation/platform/pki/subscribers"
> >
<span className="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"> <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">
Documentation{" "} Documentation{" "}
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
@@ -112,7 +112,9 @@ export const PkiTemplateListPage = () => {
/> />
</div> </div>
<div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="container mx-auto mb-6 max-w-7xl rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between"> {
subscription?.pkiLegacyTemplates && (
<div className="mb-4 flex justify-between">
<p className="text-xl font-medium text-mineshaft-100">Templates</p> <p className="text-xl font-medium text-mineshaft-100">Templates</p>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<ProjectPermissionCan <ProjectPermissionCan
@@ -134,6 +136,8 @@ export const PkiTemplateListPage = () => {
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> </div>
)
}
<TableContainer> <TableContainer>
<Table> <Table>
<THead> <THead>
@@ -2,6 +2,7 @@ import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2"; import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context"; import { useProjectPermission } from "@app/context";
import { import {
@@ -54,6 +55,10 @@ export const CertificateProfilesTab = () => {
}); });
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelectedProfile(null); setSelectedProfile(null);
createNotification({
text: `Certificate profile "${selectedProfile.slug}" deleted successfully`,
type: "success"
});
} catch (error) { } catch (error) {
console.error("Failed to delete profile:", error); console.error("Failed to delete profile:", error);
} }
@@ -122,7 +122,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
const certificateAuthorities = caData || []; const certificateAuthorities = caData || [];
const certificateTemplates = templateData?.certificateTemplates || []; const certificateTemplates = templateData?.certificateTemplates || [];
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({ const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema), resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit defaultValues: isEdit
? { ? {
@@ -260,7 +260,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="certificateAuthorityId" name="certificateAuthorityId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Certificate Authority" label="Issuing CA"
isRequired isRequired
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
@@ -296,7 +296,6 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<Select <Select
{...field} {...field}
onValueChange={(value) => { onValueChange={(value) => {
onChange(value);
if (watchedEnrollmentType === "est") { if (watchedEnrollmentType === "est") {
setValue("estConfig", { setValue("estConfig", {
disableBootstrapCaValidation: false, disableBootstrapCaValidation: false,
@@ -311,6 +310,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
}); });
setValue("estConfig", undefined); setValue("estConfig", undefined);
} }
onChange(value);
}} }}
placeholder="Select a certificate template" placeholder="Select a certificate template"
className="w-full" className="w-full"
@@ -339,7 +339,23 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
> >
<Select <Select
{...field} {...field}
onValueChange={onChange} onValueChange={(value) => {
if (value === "est") {
setValue("apiConfig", undefined);
setValue("estConfig", {
disableBootstrapCaValidation: false,
passphrase: "",
caChain: ""
});
} else {
setValue("estConfig", undefined);
setValue("apiConfig", {
autoRenew: false,
autoRenewDays: 30
});
}
onChange(value);
}}
className="w-full" className="w-full"
position="popper" position="popper"
isDisabled={Boolean(isEdit)} isDisabled={Boolean(isEdit)}
@@ -360,17 +376,17 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="estConfig.disableBootstrapCaValidation" name="estConfig.disableBootstrapCaValidation"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="border-mineshaft-600 bg-mineshaft-900 flex items-center gap-3 rounded-md border p-4">
<Checkbox <Checkbox
id="disableBootstrapCaValidation" id="disableBootstrapCaValidation"
isChecked={value} isChecked={value}
onCheckedChange={onChange} onCheckedChange={onChange}
/> />
<div className="space-y-1"> <div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100"> <span className="text-mineshaft-100 text-sm font-medium">
Disable Bootstrap CA Validation Disable Bootstrap CA Validation
</span> </span>
<p className="text-xs text-bunker-300"> <p className="text-bunker-300 text-xs">
Skip CA certificate validation during EST bootstrap phase Skip CA certificate validation during EST bootstrap phase
</p> </p>
</div> </div>
@@ -417,7 +433,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
rows={6} rows={6}
className="w-full font-mono text-xs" className="w-full font-mono text-xs"
/> />
<p className="text-xs text-bunker-400"> <p className="text-bunker-400 text-xs">
Paste the complete CA certificate chain in PEM format Paste the complete CA certificate chain in PEM format
</p> </p>
</div> </div>
@@ -478,6 +494,9 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
type="submit" type="submit"
colorSchema="primary" colorSchema="primary"
isLoading={isEdit ? updateProfile.isPending : createProfile.isPending} isLoading={isEdit ? updateProfile.isPending : createProfile.isPending}
isDisabled={
!formState.isValid || (isEdit ? updateProfile.isPending : createProfile.isPending)
}
> >
{isEdit ? "Save Changes" : "Create"} {isEdit ? "Save Changes" : "Create"}
</Button> </Button>
@@ -4,6 +4,7 @@ import {
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
TBody, TBody,
Td,
Th, Th,
THead, THead,
Tr Tr
@@ -33,14 +34,6 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
const profiles = data?.certificateProfiles || []; const profiles = data?.certificateProfiles || [];
if (isLoading) {
return <TableSkeleton columns={6} innerKey="certificate-profiles" />;
}
if (!profiles || profiles.length === 0) {
return <EmptyState title="No Certificate Profiles" />;
}
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -48,21 +41,32 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Enrollment Type</Th> <Th>Enrollment Type</Th>
<Th>Certificate Authority</Th> <Th>Issuing CA</Th>
<Th>Template</Th> <Th>Certificate Template</Th>
<Th>Certificates</Th> <Th>Certificates</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{profiles.map((profile) => ( {isLoading && <TableSkeleton columns={6} innerKey="certificate-profiles" />}
<ProfileRow {!isLoading && (!profiles || profiles.length === 0) && (
key={profile.id} <Tr>
profile={profile} <Td colSpan={6}>
onEditProfile={onEditProfile} <EmptyState title="No Certificate Profiles" />
onDeleteProfile={onDeleteProfile} </Td>
/> </Tr>
))} )}
{!isLoading &&
profiles &&
profiles.length > 0 &&
profiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
onEditProfile={onEditProfile}
onDeleteProfile={onDeleteProfile}
/>
))}
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
@@ -1,5 +1,5 @@
/* eslint-disable no-nested-ternary */ /* eslint-disable no-nested-ternary */
import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -15,11 +15,16 @@ import {
import { useProjectPermission } from "@app/context"; import { useProjectPermission } from "@app/context";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import { useGetCaById } from "@app/hooks/api/ca/queries"; import { useGetCaById } from "@app/hooks/api/ca/queries";
import { TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { usePopUp, useToggle } from "@app/hooks";
import { createNotification } from "@app/components/notifications";
import { useCallback } from "react";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
interface Props { interface Props {
profile: TCertificateProfile; profile: TCertificateProfile;
@@ -32,6 +37,27 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const { data: caData } = useGetCaById(profile.caId); const { data: caData } = useGetCaById(profile.caId);
const { popUp, handlePopUpToggle } = usePopUp([
"certificateIssuance"
] as const);
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(profile.id);
createNotification({
text: "Profile ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const { data: templateData } = useGetCertificateTemplateV2ById({ const { data: templateData } = useGetCertificateTemplateV2ById({
templateId: profile.certificateTemplateId templateId: profile.certificateTemplateId
}); });
@@ -41,6 +67,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
ProjectPermissionSub.CertificateAuthorities ProjectPermissionSub.CertificateAuthorities
); );
const canIssueCertificate = permission.can(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
const canDeleteProfile = permission.can( const canDeleteProfile = permission.can(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateAuthorities ProjectPermissionSub.CertificateAuthorities
@@ -128,6 +159,12 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1"> <DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Profile ID
</DropdownMenuItem>
{canEditProfile && ( {canEditProfile && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -139,6 +176,19 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
Edit Profile Edit Profile
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{
canIssueCertificate && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpToggle("certificateIssuance");
}}
icon={<FontAwesomeIcon icon={faPlus} />}
>
Issue Certificate
</DropdownMenuItem>
)
}
{canDeleteProfile && ( {canDeleteProfile && (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -152,6 +202,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<CertificateIssuanceModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} profileId={profile.id}/>
</Td> </Td>
</Tr> </Tr>
); );
@@ -2,6 +2,7 @@ import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2"; import { Button, DeleteActionModal } from "@app/components/v2";
import { useProjectPermission } from "@app/context"; import { useProjectPermission } from "@app/context";
import { import {
@@ -52,7 +53,11 @@ export const CertificateTemplatesV2Tab = () => {
}); });
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
setSelectedTemplate(null); setSelectedTemplate(null);
} catch (error) { createNotification({
text: `Certificate template "${selectedTemplate.slug}" deleted successfully`,
type: "success"
});
} catch (error: any) {
console.error("Failed to delete template:", error); console.error("Failed to delete template:", error);
} }
}; };
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faExclamationTriangle, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -18,7 +19,8 @@ import {
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
TextArea TextArea,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { import {
@@ -27,7 +29,7 @@ import {
} from "@app/hooks/api/certificateTemplates/mutations"; } from "@app/hooks/api/certificateTemplates/mutations";
import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types"; import { TCertificateTemplateV2New } from "@app/hooks/api/certificateTemplates/types";
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./shared/utils"; import { INCLUDE_TYPE_OPTIONS, SAN_TYPE_OPTIONS, SUBJECT_ATTRIBUTE_TYPE_OPTIONS } from "./shared/certificate-constants";
import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared"; import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared";
export type FormData = TemplateFormData; export type FormData = TemplateFormData;
@@ -39,26 +41,18 @@ interface Props {
mode?: "create" | "edit"; mode?: "create" | "edit";
} }
const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPES)[number], string> = { const ATTRIBUTE_TYPE_LABELS: Record<(typeof SUBJECT_ATTRIBUTE_TYPE_OPTIONS)[number], string> = {
common_name: "Common Name (CN)", common_name: "Common Name (CN)"
organization_name: "Organization (O)",
organization_unit: "Organizational Unit (OU)",
locality: "Locality (L)",
state: "State/Province (ST)",
country: "Country (C)",
email: "Email Address",
street_address: "Street Address",
postal_code: "Postal Code"
}; };
const SAN_TYPE_LABELS: Record<(typeof SAN_TYPES)[number], string> = { const SAN_TYPE_LABELS: Record<(typeof SAN_TYPE_OPTIONS)[number], string> = {
dns_name: "DNS Name", dns_name: "DNS Name",
ip_address: "IP Address", ip_address: "IP Address",
email: "Email", email: "Email",
uri: "URI" uri: "URI"
}; };
const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_OPTIONS)[number], string> = { const INCLUDE_TYPE_LABELS: Record<(typeof INCLUDE_TYPE_OPTIONS)[number], string> = {
mandatory: "Mandatory", mandatory: "Mandatory",
optional: "Optional", optional: "Optional",
prohibit: "Prohibited" prohibit: "Prohibited"
@@ -89,7 +83,36 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const isEdit = mode === "edit" && template; const isEdit = mode === "edit" && template;
const { control, handleSubmit, reset, watch, setValue } = useForm<FormData>({ const validateAttributeRules = (attributes: FormData["attributes"]) => {
if (!attributes) return { isValid: true, warnings: [], invalidIndices: [] };
const warnings: string[] = [];
const invalidIndices: number[] = [];
const attributesByType = attributes.reduce((acc, attr, index) => {
if (!acc[attr.type]) acc[attr.type] = [];
acc[attr.type].push({ ...attr, index });
return acc;
}, {} as Record<string, Array<(typeof attributes[0] & { index: number })>>);
Object.entries(attributesByType).forEach(([type, attrs]) => {
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory');
if (mandatoryAttrs.length > 1) {
mandatoryAttrs.forEach(attr => invalidIndices.push(attr.index));
warnings.push(`Multiple mandatory values found for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}. Only one mandatory value is allowed per attribute type.`);
}
if (mandatoryAttrs.length === 1 && attrs.length > 1) {
attrs.forEach(attr => invalidIndices.push(attr.index));
warnings.push(`When a mandatory value exists for ${ATTRIBUTE_TYPE_LABELS[type as keyof typeof ATTRIBUTE_TYPE_LABELS]}, no other values (optional or forbidden) are allowed for that attribute type.`);
}
});
return { isValid: warnings.length === 0, warnings, invalidIndices };
};
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(templateSchema), resolver: zodResolver(templateSchema),
defaultValues: isEdit defaultValues: isEdit
? { ? {
@@ -138,13 +161,42 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const watchedAttributes = watch("attributes") || []; const watchedAttributes = watch("attributes") || [];
const watchedSans = watch("subjectAlternativeNames") || []; const watchedSans = watch("subjectAlternativeNames") || [];
const watchedKeyUsages = watch("keyUsages"); const watchedKeyUsages = watch("keyUsages") || { requiredUsages: [], optionalUsages: [] };
const watchedExtendedKeyUsages = watch("extendedKeyUsages"); const watchedExtendedKeyUsages = watch("extendedKeyUsages") || { requiredUsages: [], optionalUsages: [] };
const attributeValidation = useMemo(() =>
validateAttributeRules(watchedAttributes),
[watchedAttributes]
);
const onFormSubmit = async (data: FormData) => { const onFormSubmit = async (data: FormData) => {
try { try {
if (!currentProject?.id && !isEdit) return; if (!currentProject?.id && !isEdit) return;
if (!attributeValidation.isValid) {
createNotification({
text: "Please fix validation errors before submitting",
type: "error"
});
return;
}
const hasEmptyAttributeValues = data.attributes?.some(attr =>
!attr.value || attr.value.length === 0 || attr.value.some(v => !v.trim())
);
const hasEmptySanValues = data.subjectAlternativeNames?.some(san =>
!san.value || san.value.length === 0 || san.value.some(v => !v.trim())
);
if (hasEmptyAttributeValues || hasEmptySanValues) {
createNotification({
text: "All values must be non-empty. Use wildcards (*) if needed.",
type: "error"
});
return;
}
if (isEdit) { if (isEdit) {
const updateData = { const updateData = {
templateId: template.id, templateId: template.id,
@@ -221,9 +273,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const addAttribute = () => { const addAttribute = () => {
const newAttribute = { const newAttribute = {
type: SUBJECT_ATTRIBUTE_TYPES[0], type: SUBJECT_ATTRIBUTE_TYPE_OPTIONS[0],
include: INCLUDE_OPTIONS[1], include: INCLUDE_TYPE_OPTIONS[1],
value: [] value: ["*"]
}; };
setValue("attributes", [...watchedAttributes, newAttribute]); setValue("attributes", [...watchedAttributes, newAttribute]);
}; };
@@ -235,9 +287,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
const addSan = () => { const addSan = () => {
const newSan = { const newSan = {
type: SAN_TYPES[0], type: SAN_TYPE_OPTIONS[0],
include: INCLUDE_OPTIONS[1], include: INCLUDE_TYPE_OPTIONS[1],
value: [] value: ["*"]
}; };
setValue("subjectAlternativeNames", [...watchedSans, newSan]); setValue("subjectAlternativeNames", [...watchedSans, newSan]);
}; };
@@ -247,42 +299,18 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}; };
const toggleKeyUsage = (usage: string, type: "required" | "optional") => { const handleKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => {
const current = watchedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = (otherList || []).filter((u) => u !== usage);
const newCurrentList = currentList?.includes(usage)
? currentList.filter((u) => u !== usage)
: [...(currentList || []), usage];
setValue("keyUsages", { setValue("keyUsages", {
[`${type}Usages`]: newCurrentList, requiredUsages: usages.requiredUsages as any,
[`${otherType}Usages`]: newOtherList optionalUsages: usages.optionalUsages as any
} as any); });
}; };
const toggleExtendedKeyUsage = (usage: string, type: "required" | "optional") => { const handleExtendedKeyUsagesChange = (usages: { requiredUsages: string[]; optionalUsages: string[] }) => {
const current = watchedExtendedKeyUsages || { requiredUsages: [], optionalUsages: [] };
const otherType = type === "required" ? "optional" : "required";
const currentList = Array.isArray(current[`${type}Usages`]) ? current[`${type}Usages`] : [];
const otherList = Array.isArray(current[`${otherType}Usages`])
? current[`${otherType}Usages`]
: [];
const newOtherList = (otherList || []).filter((u) => u !== usage);
const newCurrentList = currentList?.includes(usage)
? currentList.filter((u) => u !== usage)
: [...(currentList || []), usage];
setValue("extendedKeyUsages", { setValue("extendedKeyUsages", {
[`${type}Usages`]: newCurrentList, requiredUsages: usages.requiredUsages as any,
[`${otherType}Usages`]: newOtherList optionalUsages: usages.optionalUsages as any
} as any); });
}; };
return ( return (
@@ -297,7 +325,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
> >
<ModalContent <ModalContent
className="max-w-4xl" className="max-w-4xl"
title={isEdit ? "Edit Certificate Template V2" : "Create Certificate Template V2"} title={isEdit ? "Edit Certificate Template" : "Create Certificate Template"}
subTitle={ subTitle={
isEdit isEdit
? `Update configuration for ${template?.slug}` ? `Update configuration for ${template?.slug}`
@@ -306,42 +334,36 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
> >
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
<Accordion type="multiple" defaultValue={["basic"]} className="w-full"> <Accordion type="multiple" defaultValue={["basic"]} className="w-full">
<AccordionItem value="basic"> <div className="space-y-4">
<AccordionTrigger>Basic Information</AccordionTrigger> <Controller
<AccordionContent> control={control}
<div className="space-y-4"> name="slug"
<Controller render={({ field, fieldState: { error } }) => (
control={control} <FormControl
name="slug" label="Template Name"
render={({ field, fieldState: { error } }) => ( isRequired
<FormControl isError={Boolean(error)}
label="Template Name" errorText={error?.message}
isRequired >
isError={Boolean(error)} <Input {...field} placeholder="Enter template name" className="w-full" />
errorText={error?.message} </FormControl>
> )}
<Input {...field} placeholder="Enter template name" className="w-full" /> />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description"
isError={Boolean(error)}
errorText={error?.message}
>
<TextArea {...field} placeholder="Enter template description" rows={3} />
</FormControl>
)}
/>
</div>
<AccordionItem value="attributes"> <AccordionItem value="attributes">
<AccordionTrigger>Subject Attributes</AccordionTrigger> <AccordionTrigger>Subject Attributes</AccordionTrigger>
<AccordionContent> <AccordionContent>
@@ -351,92 +373,173 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="button" type="button"
onClick={addAttribute} onClick={addAttribute}
size="sm" size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
> >
Add Attribute Add Attribute
</Button> </Button>
</div> </div>
{/* Validation warnings */}
{!attributeValidation.isValid && attributeValidation.warnings.length > 0 && (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faExclamationTriangle} className="text-yellow-500 mt-0.5" />
<div className="flex-1">
<h4 className="text-yellow-500 font-medium text-sm">Validation Warnings</h4>
<ul className="text-yellow-400 text-sm mt-1 space-y-1">
{attributeValidation.warnings.map((warning, index) => (
<li key={index}>• {warning}</li>
))}
</ul>
</div>
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{watchedAttributes.length === 0 ? ( {watchedAttributes.length === 0 ? (
<div className="py-8 text-center text-bunker-300"> <div className="text-bunker-300 py-8 text-center">
No subject attributes configured yet. Click &quot;Add Attribute&quot; to get No subject attributes configured yet. Click &quot;Add Attribute&quot; to get
started. started.
</div> </div>
) : ( ) : (
watchedAttributes.map((attr, index) => ( watchedAttributes.map((attr, index) => {
<div const isInvalid = attributeValidation.invalidIndices.includes(index);
key={`attr-${attr.type}`} const errorClass = isInvalid ? "border-red-500 focus:border-red-500" : "";
className="flex flex-col space-y-2 rounded-md border border-mineshaft-600 p-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-mineshaft-200">
{ATTRIBUTE_TYPE_LABELS[attr.type] || attr.type}
</span>
</div>
<div className="flex gap-3"> return (
<Select <div key={`attr-${attr.type}-${attr.include}-${index}`} className="flex items-start gap-2">
value={attr.type} {isInvalid ? (
onValueChange={(value) => { <Tooltip content="This attribute has validation errors. Check the warnings above for details.">
const newAttributes = [...watchedAttributes]; <div className="flex items-start gap-2 flex-1">
newAttributes[index] = { ...attr, type: value as any }; <Select
setValue("attributes", newAttributes); value={attr.type}
}} onValueChange={(value) => {
position="popper" const newAttributes = [...watchedAttributes];
> newAttributes[index] = { ...attr, type: value as any };
{SUBJECT_ATTRIBUTE_TYPES.map((type) => ( setValue("attributes", newAttributes);
<SelectItem key={type} value={type}> }}
{ATTRIBUTE_TYPE_LABELS[type]} className={`w-48 ${errorClass}`}
</SelectItem> >
))} {SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
</Select> <SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select <Select
value={attr.include} value={attr.include}
onValueChange={(value) => { onValueChange={(value) => {
const newAttributes = [...watchedAttributes]; const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any }; newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes); setValue("attributes", newAttributes);
}} }}
position="popper" className={`w-32 ${errorClass}`}
> >
{INCLUDE_OPTIONS.map((type) => ( {INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}> <SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]} {INCLUDE_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
<Input <Input
placeholder="Pattern/Value (optional)" placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""} value={attr.value?.[0] || ""}
onChange={(e) => { onChange={(e) => {
const newAttributes = [...watchedAttributes]; const newAttributes = [...watchedAttributes];
newAttributes[index] = { newAttributes[index] = {
...attr, ...attr,
value: e.target.value ? [e.target.value] : [] value: e.target.value.trim() ? [e.target.value.trim()] : []
}; };
setValue("attributes", newAttributes); setValue("attributes", newAttributes);
}} }}
/> className={`flex-1 ${errorClass} ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
</div>
</Tooltip>
) : (
<>
<Select
value={attr.type}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, type: value as any };
setValue("attributes", newAttributes);
}}
className="w-48"
>
{SUBJECT_ATTRIBUTE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{ATTRIBUTE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select
value={attr.include}
onValueChange={(value) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = { ...attr, include: value as any };
setValue("attributes", newAttributes);
}}
className="w-32"
>
{INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Input
placeholder="Pattern/Value (required - use * for wildcards)"
value={attr.value?.[0] || ""}
onChange={(e) => {
const newAttributes = [...watchedAttributes];
newAttributes[index] = {
...attr,
value: e.target.value.trim() ? [e.target.value.trim()] : []
};
setValue("attributes", newAttributes);
}}
className={`flex-1 ${
attr.value && attr.value.length > 0 && attr.value[0] === ""
? "border-red-500 focus:border-red-500"
: ""
}`}
required
/>
</>
)}
{watchedAttributes.length > 1 && (
<IconButton <IconButton
ariaLabel="delete attribute" ariaLabel="Remove Attribute"
variant="plain" variant="plain"
size="sm"
onClick={() => removeAttribute(index)} onClick={() => removeAttribute(index)}
> >
<FontAwesomeIcon icon={faTrash} className="text-red-500" /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</div> )}
</div> </div>
)) );
})
)} )}
</div> </div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="san"> <AccordionItem value="san" className="mt-4">
<AccordionTrigger>Subject Alternative Names</AccordionTrigger> <AccordionTrigger>Subject Alternative Names</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
@@ -445,6 +548,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="button" type="button"
onClick={addSan} onClick={addSan}
size="sm" size="sm"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
> >
Add SAN Add SAN
@@ -453,72 +557,72 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
<div className="space-y-2"> <div className="space-y-2">
{watchedSans.length === 0 ? ( {watchedSans.length === 0 ? (
<div className="py-8 text-center text-bunker-300"> <div className="text-bunker-300 py-8 text-center">
No subject alternative names configured yet. Click &quot;Add SAN&quot; to No subject alternative names configured yet. Click &quot;Add SAN&quot; to
get started. get started.
</div> </div>
) : ( ) : (
watchedSans.map((san, index) => ( watchedSans.map((san, index) => (
<div <div key={`san-${san.type}-${san.include}-${index}`} className="flex items-start gap-2">
key={`san-${san.type}`} <Select
className="flex flex-col space-y-4 rounded-md border border-mineshaft-600 p-4" value={san.type}
> onValueChange={(value) => {
<div className="flex gap-3"> const newSans = [...watchedSans];
<Select newSans[index] = { ...san, type: value as any };
value={san.type} setValue("subjectAlternativeNames", newSans);
onValueChange={(value) => { }}
const newSans = [...watchedSans]; className="w-24"
newSans[index] = { ...san, type: value as any }; >
setValue("subjectAlternativeNames", newSans); {SAN_TYPE_OPTIONS.map((type) => (
}} <SelectItem key={type} value={type}>
position="popper" {SAN_TYPE_LABELS[type]}
> </SelectItem>
{SAN_TYPES.map((type) => ( ))}
<SelectItem key={type} value={type}> </Select>
{SAN_TYPE_LABELS[type]}
</SelectItem>
))}
</Select>
<Select <Select
value={san.include} value={san.include}
onValueChange={(value) => { onValueChange={(value) => {
const newSans = [...watchedSans]; const newSans = [...watchedSans];
newSans[index] = { ...san, include: value as any }; newSans[index] = { ...san, include: value as any };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
position="popper" className="w-32"
> >
{INCLUDE_OPTIONS.map((type) => ( {INCLUDE_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}> <SelectItem key={type} value={type}>
{INCLUDE_TYPE_LABELS[type]} {INCLUDE_TYPE_LABELS[type]}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
<Input <Input
placeholder="Pattern/Value (optional)" placeholder="Pattern/Value (required - use * for wildcards)"
value={san.value?.[0] || ""} value={san.value?.[0] || ""}
onChange={(e) => { onChange={(e) => {
const newSans = [...watchedSans]; const newSans = [...watchedSans];
newSans[index] = { newSans[index] = {
...san, ...san,
value: e.target.value ? [e.target.value] : [] value: e.target.value.trim() ? [e.target.value.trim()] : []
}; };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
/> className={`flex-1 ${
<div className="flex items-center justify-between"> san.value && san.value.length > 0 && san.value[0] === ""
<IconButton ? "border-red-500 focus:border-red-500"
onClick={() => removeSan(index)} : ""
size="sm" }`}
variant="plain" required
ariaLabel="Remove SAN" />
>
<FontAwesomeIcon icon={faTrash} className="text-red-500" /> <IconButton
</IconButton> ariaLabel="Remove SAN"
</div> variant="plain"
</div> size="sm"
onClick={() => removeSan(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div> </div>
)) ))
)} )}
@@ -527,19 +631,19 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="usages"> <AccordionItem value="usages" className="mt-4">
<AccordionTrigger>Key Usages</AccordionTrigger> <AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
<KeyUsagesSection <KeyUsagesSection
watchedKeyUsages={watchedKeyUsages} watchedKeyUsages={watchedKeyUsages}
watchedExtendedKeyUsages={watchedExtendedKeyUsages} watchedExtendedKeyUsages={watchedExtendedKeyUsages}
toggleKeyUsage={toggleKeyUsage} onKeyUsagesChange={handleKeyUsagesChange}
toggleExtendedKeyUsage={toggleExtendedKeyUsage} onExtendedKeyUsagesChange={handleExtendedKeyUsagesChange}
/> />
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="constraints"> <AccordionItem value="constraints" className="mt-4">
<AccordionTrigger>Constraints</AccordionTrigger> <AccordionTrigger>Constraints</AccordionTrigger>
<AccordionContent> <AccordionContent>
<div className="space-y-4"> <div className="space-y-4">
@@ -637,7 +741,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
/> />
<label <label
htmlFor={`sig-alg-${alg}`} htmlFor={`sig-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200" className="text-mineshaft-200 cursor-pointer text-sm font-medium"
> >
{alg} {alg}
</label> </label>
@@ -717,7 +821,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
/> />
<label <label
htmlFor={`key-alg-${alg}`} htmlFor={`key-alg-${alg}`}
className="cursor-pointer text-sm font-medium text-mineshaft-200" className="text-mineshaft-200 cursor-pointer text-sm font-medium"
> >
{alg} {alg}
</label> </label>
@@ -765,6 +869,11 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
type="submit" type="submit"
colorSchema="primary" colorSchema="primary"
isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending} isLoading={isEdit ? updateTemplate.isPending : createTemplate.isPending}
isDisabled={
!formState.isValid ||
!attributeValidation.isValid ||
(isEdit ? updateTemplate.isPending : createTemplate.isPending)
}
> >
{isEdit ? "Save Changes" : "Create"} {isEdit ? "Save Changes" : "Create"}
</Button> </Button>
@@ -56,14 +56,6 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
return new Date(dateString).toLocaleDateString(); return new Date(dateString).toLocaleDateString();
}; };
if (isLoading) {
return <TableSkeleton columns={4} innerKey="certificate-templates" />;
}
if (!templates || templates.length === 0) {
return <EmptyState title="No Certificate Templates" />;
}
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -75,7 +67,15 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{templates.map((template) => ( {isLoading && <TableSkeleton columns={3} innerKey="certificate-templates" />}
{!isLoading && (!templates || templates.length === 0) && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No Certificate Templates" />
</Td>
</Tr>
)}
{!isLoading && templates && templates.length > 0 && templates.map((template) => (
<Tr <Tr
key={template.id} key={template.id}
className="h-10 transition-colors duration-100 hover:bg-mineshaft-700" className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"
@@ -0,0 +1,164 @@
export enum CertSubjectAlternativeNameType {
DNS_NAME = "dns_name",
IP_ADDRESS = "ip_address",
EMAIL = "email",
URI = "uri"
}
export enum CertKeyUsageType {
DIGITAL_SIGNATURE = "digital_signature",
KEY_ENCIPHERMENT = "key_encipherment",
NON_REPUDIATION = "non_repudiation",
DATA_ENCIPHERMENT = "data_encipherment",
KEY_AGREEMENT = "key_agreement",
KEY_CERT_SIGN = "key_cert_sign",
CRL_SIGN = "crl_sign",
ENCIPHER_ONLY = "encipher_only",
DECIPHER_ONLY = "decipher_only"
}
export enum CertExtendedKeyUsageType {
CLIENT_AUTH = "client_auth",
SERVER_AUTH = "server_auth",
CODE_SIGNING = "code_signing",
EMAIL_PROTECTION = "email_protection",
OCSP_SIGNING = "ocsp_signing",
TIME_STAMPING = "time_stamping"
}
export enum CertIncludeType {
MANDATORY = "mandatory",
OPTIONAL = "optional",
PROHIBIT = "prohibit"
}
export enum CertDurationUnit {
DAYS = "days",
MONTHS = "months",
YEARS = "years"
}
export enum CertSubjectAttributeType {
COMMON_NAME = "common_name"
}
export const formatSANType = (type: CertSubjectAlternativeNameType): string => {
switch (type) {
case CertSubjectAlternativeNameType.DNS_NAME:
return "DNS Name";
case CertSubjectAlternativeNameType.IP_ADDRESS:
return "IP Address";
case CertSubjectAlternativeNameType.EMAIL:
return "Email";
case CertSubjectAlternativeNameType.URI:
return "URI";
default:
return type;
}
};
export const formatKeyUsage = (usage: CertKeyUsageType): string => {
switch (usage) {
case CertKeyUsageType.DIGITAL_SIGNATURE:
return "Digital Signature";
case CertKeyUsageType.KEY_ENCIPHERMENT:
return "Key Encipherment";
case CertKeyUsageType.NON_REPUDIATION:
return "Non Repudiation";
case CertKeyUsageType.DATA_ENCIPHERMENT:
return "Data Encipherment";
case CertKeyUsageType.KEY_AGREEMENT:
return "Key Agreement";
case CertKeyUsageType.KEY_CERT_SIGN:
return "Key Cert Sign";
case CertKeyUsageType.CRL_SIGN:
return "CRL Sign";
case CertKeyUsageType.ENCIPHER_ONLY:
return "Encipher Only";
case CertKeyUsageType.DECIPHER_ONLY:
return "Decipher Only";
default:
return usage;
}
};
export const formatExtendedKeyUsage = (usage: CertExtendedKeyUsageType): string => {
switch (usage) {
case CertExtendedKeyUsageType.CLIENT_AUTH:
return "Client Auth";
case CertExtendedKeyUsageType.SERVER_AUTH:
return "Server Auth";
case CertExtendedKeyUsageType.CODE_SIGNING:
return "Code Signing";
case CertExtendedKeyUsageType.EMAIL_PROTECTION:
return "Email Protection";
case CertExtendedKeyUsageType.OCSP_SIGNING:
return "OCSP Signing";
case CertExtendedKeyUsageType.TIME_STAMPING:
return "Time Stamping";
default:
return usage;
}
};
export const formatSubjectAttributeType = (type: CertSubjectAttributeType): string => {
switch (type) {
case CertSubjectAttributeType.COMMON_NAME:
return "Common Name";
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";
default:
return include;
}
};
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;
default:
throw new Error(`Unknown SAN type: ${type}`);
}
};
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);
@@ -1,141 +1,152 @@
import { Button } from "@app/components/v2"; import { Checkbox } from "@app/components/v2";
import { import {
EXTENDED_KEY_USAGES, CertExtendedKeyUsageType,
formatUsageName, CertKeyUsageType,
getUsageState, formatExtendedKeyUsage,
KEY_USAGES, formatKeyUsage,
toggleUsageState EXTENDED_KEY_USAGE_OPTIONS,
} from "./utils"; KEY_USAGE_OPTIONS
} from "./certificate-constants";
type UsageToggleProps = { type UsageState = "mandatory" | "optional" | undefined;
value: "required" | "optional" | undefined;
onChange: (value: "required" | "optional" | undefined) => void; type ThreeStateCheckboxProps = {
value: UsageState;
onChange: (newValue: UsageState) => void;
label: string;
id: string;
}; };
export const UsageToggle = ({ value, onChange }: UsageToggleProps) => { const ThreeStateCheckbox = ({ value, onChange, label, id }: ThreeStateCheckboxProps) => {
const handleClick = () => {
if (value === undefined) {
onChange("optional");
} else if (value === "optional") {
onChange("mandatory");
} else {
onChange(undefined);
}
};
const getCheckboxState = () => {
if (value) return true;
return false;
};
const getIndeterminateState = () => {
return value === "optional";
};
const getStateLabel = () => {
if (value === "mandatory") return " (Mandatory)";
if (value === "optional") return " (Optional)";
return "";
};
return ( return (
<div className="border-mineshaft-600 bg-mineshaft-800 flex gap-x-0.5 rounded-md border p-1"> <div className="flex items-center space-x-3">
<Button <Checkbox
variant="outline_bg" id={id}
onClick={() => { isChecked={getCheckboxState()}
onChange(value === "required" ? undefined : "required"); isIndeterminate={getIndeterminateState()}
}} onCheckedChange={handleClick}
size="xs" />
className={`${ <label
value === "required" ? "bg-mineshaft-500" : "bg-transparent" htmlFor={id}
} hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`} className="text-mineshaft-200 cursor-pointer text-sm font-medium"
> >
Required {label}
</Button> {value && (
<Button <span className="text-mineshaft-400 text-xs ml-1">
variant="outline_bg" {getStateLabel()}
onClick={() => { </span>
onChange(value === "optional" ? undefined : "optional"); )}
}} </label>
size="xs"
className={`${
value === "optional" ? "bg-mineshaft-500" : "bg-transparent"
} hover:bg-mineshaft-600 min-w-[2.4rem] rounded border-none`}
>
Optional
</Button>
</div> </div>
); );
}; };
type KeyUsagesSectionProps = { type KeyUsagesSectionProps = {
watchedKeyUsages?: { watchedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
requiredUsages?: string[]; watchedExtendedKeyUsages?: { requiredUsages?: string[]; optionalUsages?: string[] };
optionalUsages?: string[]; onKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
}; onExtendedKeyUsagesChange: (usages: { requiredUsages: string[]; optionalUsages: string[] }) => void;
watchedExtendedKeyUsages?: {
requiredUsages?: string[];
optionalUsages?: string[];
};
toggleKeyUsage: (usage: string, type: "required" | "optional") => void;
toggleExtendedKeyUsage: (usage: string, type: "required" | "optional") => void;
}; };
export const KeyUsagesSection = ({ export const KeyUsagesSection = ({
watchedKeyUsages, watchedKeyUsages = { requiredUsages: [], optionalUsages: [] },
watchedExtendedKeyUsages, watchedExtendedKeyUsages = { requiredUsages: [], optionalUsages: [] },
toggleKeyUsage, onKeyUsagesChange,
toggleExtendedKeyUsage onExtendedKeyUsagesChange
}: KeyUsagesSectionProps) => { }: 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 handleKeyUsageChange = (usage: CertKeyUsageType, newState: UsageState) => {
const currentRequired = watchedKeyUsages.requiredUsages || [];
const currentOptional = watchedKeyUsages.optionalUsages || [];
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];
}
onKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional });
};
const handleExtendedKeyUsageChange = (usage: CertExtendedKeyUsageType, newState: UsageState) => {
const currentRequired = watchedExtendedKeyUsages.requiredUsages || [];
const currentOptional = watchedExtendedKeyUsages.optionalUsages || [];
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];
}
onExtendedKeyUsagesChange({ requiredUsages: newRequired, optionalUsages: newOptional });
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Key Usages</h3> <h3 className="text-mineshaft-200 text-sm font-medium">Key Usages</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2 pl-2">
{KEY_USAGES.map((usage) => { {KEY_USAGE_OPTIONS.map((usage) => (
const requiredUsages = Array.isArray(watchedKeyUsages?.requiredUsages) <ThreeStateCheckbox
? watchedKeyUsages.requiredUsages key={usage}
: []; id={`key-usage-${usage}`}
const optionalUsages = Array.isArray(watchedKeyUsages?.optionalUsages) label={formatKeyUsage(usage)}
? watchedKeyUsages.optionalUsages value={getUsageState(usage, watchedKeyUsages)}
: []; onChange={(newState) => handleKeyUsageChange(usage, newState)}
/>
const currentState = getUsageState(usage, requiredUsages, optionalUsages); ))}
return (
<div key={usage} className="flex items-center justify-between p-2">
<span className="text-mineshaft-300 text-sm capitalize">
{formatUsageName(usage)}
</span>
<UsageToggle
value={currentState}
onChange={(newValue) => {
toggleUsageState(
usage,
newValue,
requiredUsages,
optionalUsages,
(u) => toggleKeyUsage(u, "required"),
(u) => toggleKeyUsage(u, "optional")
);
}}
/>
</div>
);
})}
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-mineshaft-200 text-sm font-medium">Extended Key Usages</h3> <h3 className="text-mineshaft-200 text-sm font-medium">Extended Key Usages</h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-2 pl-2">
{EXTENDED_KEY_USAGES.map((usage) => { {EXTENDED_KEY_USAGE_OPTIONS.map((usage) => (
const requiredUsages = Array.isArray(watchedExtendedKeyUsages?.requiredUsages) <ThreeStateCheckbox
? watchedExtendedKeyUsages.requiredUsages key={usage}
: []; id={`ext-key-usage-${usage}`}
const optionalUsages = Array.isArray(watchedExtendedKeyUsages?.optionalUsages) label={formatExtendedKeyUsage(usage)}
? watchedExtendedKeyUsages.optionalUsages value={getUsageState(usage, watchedExtendedKeyUsages)}
: []; onChange={(newState) => handleExtendedKeyUsageChange(usage, newState)}
/>
const currentState = getUsageState(usage, requiredUsages, optionalUsages); ))}
return (
<div key={usage} className="flex items-center justify-between p-2">
<span className="text-mineshaft-300 text-sm capitalize">
{formatUsageName(usage)}
</span>
<UsageToggle
value={currentState}
onChange={(newValue) => {
toggleUsageState(
usage,
newValue,
requiredUsages,
optionalUsages,
(u) => toggleExtendedKeyUsage(u, "required"),
(u) => toggleExtendedKeyUsage(u, "optional")
);
}}
/>
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
@@ -1,33 +1,64 @@
import { z } from "zod"; import { z } from "zod";
import { INCLUDE_OPTIONS, SAN_TYPES, SUBJECT_ATTRIBUTE_TYPES } from "./utils"; import {
CertDurationUnit,
CertExtendedKeyUsageType,
CertIncludeType,
CertKeyUsageType,
CertSubjectAlternativeNameType,
CertSubjectAttributeType
} from "./certificate-constants";
export const attributeSchema = z.object({ export const attributeSchema = z.object({
type: z.enum(SUBJECT_ATTRIBUTE_TYPES), type: z.nativeEnum(CertSubjectAttributeType),
include: z.enum(INCLUDE_OPTIONS), include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional() value: z.array(z.string().min(1, "Value cannot be empty")).optional()
}); });
export const sanSchema = z.object({ export const sanSchema = z.object({
type: z.enum(SAN_TYPES), type: z.nativeEnum(CertSubjectAlternativeNameType),
include: z.enum(INCLUDE_OPTIONS), include: z.nativeEnum(CertIncludeType),
value: z.array(z.string()).optional() value: z.array(z.string().min(1, "Value cannot be empty")).optional()
}); });
export const templateSchema = z.object({ export const templateSchema = z.object({
slug: z.string().trim().min(1, "Template name is required"), slug: z.string().trim().min(1, "Template name is required"),
description: z.string().optional(), description: z.string().optional(),
attributes: z.array(attributeSchema).optional(), attributes: z.array(attributeSchema).optional().refine((attributes) => {
if (!attributes) return true;
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 [, attrs] of Object.entries(attributesByType)) {
const mandatoryAttrs = attrs.filter(attr => attr.include === 'mandatory');
if (mandatoryAttrs.length > 1) {
return false;
}
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"
}),
keyUsages: z keyUsages: z
.object({ .object({
requiredUsages: z.array(z.string()).optional(), requiredUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(),
optionalUsages: z.array(z.string()).optional() optionalUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional()
}) })
.optional(), .optional(),
extendedKeyUsages: z extendedKeyUsages: z
.object({ .object({
requiredUsages: z.array(z.string()).optional(), requiredUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(),
optionalUsages: z.array(z.string()).optional() optionalUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional()
}) })
.optional(), .optional(),
subjectAlternativeNames: z.array(sanSchema).optional(), subjectAlternativeNames: z.array(sanSchema).optional(),
@@ -36,13 +67,13 @@ export const templateSchema = z.object({
maxDuration: z maxDuration: z
.object({ .object({
value: z.number().min(1, "Duration must be at least 1"), value: z.number().min(1, "Duration must be at least 1"),
unit: z.enum(["days", "months", "years"]) unit: z.nativeEnum(CertDurationUnit)
}) })
.optional(), .optional(),
minDuration: z minDuration: z
.object({ .object({
value: z.number().min(1, "Duration must be at least 1"), value: z.number().min(1, "Duration must be at least 1"),
unit: z.enum(["days", "months", "years"]) unit: z.nativeEnum(CertDurationUnit)
}) })
.optional() .optional()
}) })
@@ -1,47 +1,27 @@
export const KEY_USAGES = [ import {
"digital_signature", CertExtendedKeyUsageType,
"key_encipherment", CertKeyUsageType,
"non_repudiation", formatExtendedKeyUsage,
"data_encipherment", formatKeyUsage
"key_agreement", } from "./certificate-constants";
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
] as const;
export const EXTENDED_KEY_USAGES = [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
] as const;
export const SUBJECT_ATTRIBUTE_TYPES = [
"common_name",
"organization_name",
"organization_unit",
"locality",
"state",
"country",
"email",
"street_address",
"postal_code"
] as const;
export const SAN_TYPES = ["dns_name", "ip_address", "email", "uri"] as const;
export const INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const;
export const formatUsageName = (usage: string): string => { export const formatUsageName = (usage: string): string => {
try {
if (Object.values(CertKeyUsageType).includes(usage as CertKeyUsageType)) {
return formatKeyUsage(usage as CertKeyUsageType);
}
if (Object.values(CertExtendedKeyUsageType).includes(usage as CertExtendedKeyUsageType)) {
return formatExtendedKeyUsage(usage as CertExtendedKeyUsageType);
}
} catch {
}
return usage.replace(/_/g, " "); return usage.replace(/_/g, " ");
}; };
export const getUsageState = ( export const getUsageState = (
usage: string, usage: CertKeyUsageType | CertExtendedKeyUsageType,
requiredUsages: string[], requiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
optionalUsages: string[] optionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[]
): "required" | "optional" | undefined => { ): "required" | "optional" | undefined => {
if (requiredUsages.includes(usage)) return "required"; if (requiredUsages.includes(usage)) return "required";
if (optionalUsages.includes(usage)) return "optional"; if (optionalUsages.includes(usage)) return "optional";
@@ -49,12 +29,12 @@ export const getUsageState = (
}; };
export const toggleUsageState = ( export const toggleUsageState = (
usage: string, usage: CertKeyUsageType | CertExtendedKeyUsageType,
newState: "required" | "optional" | undefined, newState: "required" | "optional" | undefined,
currentRequiredUsages: string[], currentRequiredUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
currentOptionalUsages: string[], currentOptionalUsages: (CertKeyUsageType | CertExtendedKeyUsageType)[],
toggleRequired: (usage: string) => void, toggleRequired: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void,
toggleOptional: (usage: string) => void toggleOptional: (usage: CertKeyUsageType | CertExtendedKeyUsageType) => void
) => { ) => {
const isRequired = currentRequiredUsages.includes(usage); const isRequired = currentRequiredUsages.includes(usage);
const isOptional = currentOptionalUsages.includes(usage); const isOptional = currentOptionalUsages.includes(usage);
@@ -7,6 +7,7 @@ import { Tooltip } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/context"; } from "@app/context";
@@ -215,6 +216,13 @@ const PkiTemplatePolicyActionSchema = z.object({
[ProjectPermissionPkiTemplateActions.IssueCert]: z.boolean().optional(), [ProjectPermissionPkiTemplateActions.IssueCert]: z.boolean().optional(),
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional() [ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
}); });
const CertificateProfilePolicyActionSchema = z.object({
[ProjectPermissionCertificateProfileActions.Read]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Create]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Edit]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.Delete]: z.boolean().optional(),
[ProjectPermissionCertificateProfileActions.IssueCert]: z.boolean().optional()
});
const SecretEventsPolicyActionSchema = z.object({ const SecretEventsPolicyActionSchema = z.object({
[ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(), [ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(),
@@ -385,6 +393,12 @@ export const projectRoleFormSchema = z.object({
}) })
.array() .array()
.default([]), .default([]),
[ProjectPermissionSub.CertificateProfiles]: CertificateProfilePolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default( [ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[] []
), ),
@@ -465,6 +479,7 @@ export const isConditionalSubjects = (
subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.SecretRotation ||
subject === ProjectPermissionSub.PkiSubscribers || subject === ProjectPermissionSub.PkiSubscribers ||
subject === ProjectPermissionSub.CertificateTemplates || subject === ProjectPermissionSub.CertificateTemplates ||
subject === ProjectPermissionSub.CertificateProfiles ||
subject === ProjectPermissionSub.SecretSyncs || subject === ProjectPermissionSub.SecretSyncs ||
subject === ProjectPermissionSub.PkiSyncs || subject === ProjectPermissionSub.PkiSyncs ||
subject === ProjectPermissionSub.SecretEvents || subject === ProjectPermissionSub.SecretEvents ||
@@ -808,7 +823,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canDelete = action.includes(ProjectPermissionActions.Delete); const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create); const canCreate = action.includes(ProjectPermissionActions.Create);
if (!formVal[subject]) formVal[subject] = [{}]; if (!formVal[subject]) {
formVal[subject] = [{ conditions: [] }];
}
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true; if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
@@ -1138,6 +1155,33 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted inverted
}); });
return;
}
if (subject === ProjectPermissionSub.CertificateProfiles) {
if (!formVal[subject]) formVal[subject] = [];
formVal[subject]!.push({
[ProjectPermissionCertificateProfileActions.Edit]: action.includes(
ProjectPermissionCertificateProfileActions.Edit
),
[ProjectPermissionCertificateProfileActions.Delete]: action.includes(
ProjectPermissionCertificateProfileActions.Delete
),
[ProjectPermissionCertificateProfileActions.Create]: action.includes(
ProjectPermissionCertificateProfileActions.Create
),
[ProjectPermissionCertificateProfileActions.Read]: action.includes(
ProjectPermissionCertificateProfileActions.Read
),
[ProjectPermissionCertificateProfileActions.IssueCert]: action.includes(
ProjectPermissionCertificateProfileActions.IssueCert
),
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
} }
if (subject === ProjectPermissionSub.PamAccounts) { if (subject === ProjectPermissionSub.PamAccounts) {
@@ -1522,6 +1566,16 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "List Certificates", value: ProjectPermissionPkiTemplateActions.ListCerts } { label: "List Certificates", value: ProjectPermissionPkiTemplateActions.ListCerts }
] ]
}, },
[ProjectPermissionSub.CertificateProfiles]: {
title: "Certificate Profiles",
actions: [
{ label: "Read", value: ProjectPermissionCertificateProfileActions.Read },
{ label: "Create", value: ProjectPermissionCertificateProfileActions.Create },
{ label: "Modify", value: ProjectPermissionCertificateProfileActions.Edit },
{ label: "Remove", value: ProjectPermissionCertificateProfileActions.Delete },
{ label: "Issue Certificates", value: ProjectPermissionCertificateProfileActions.IssueCert }
]
},
[ProjectPermissionSub.SshCertificateAuthorities]: { [ProjectPermissionSub.SshCertificateAuthorities]: {
title: "SSH Certificate Authorities", title: "SSH Certificate Authorities",
actions: [ actions: [
@@ -1869,6 +1923,7 @@ const CertificateManagerPermissionSubjects = (enabled = false) => ({
[ProjectPermissionSub.PkiSyncs]: enabled, [ProjectPermissionSub.PkiSyncs]: enabled,
[ProjectPermissionSub.CertificateAuthorities]: enabled, [ProjectPermissionSub.CertificateAuthorities]: enabled,
[ProjectPermissionSub.CertificateTemplates]: enabled, [ProjectPermissionSub.CertificateTemplates]: enabled,
[ProjectPermissionSub.CertificateProfiles]: enabled,
[ProjectPermissionSub.Certificates]: enabled [ProjectPermissionSub.Certificates]: enabled
}); });