Add self-sign certs support

This commit is contained in:
Carlos Monastyrski
2025-11-22 00:33:30 -03:00
parent 1233ff8a5f
commit 933830e17e
20 changed files with 1085 additions and 135 deletions
@@ -7,6 +7,7 @@ import { ormify, selectAllTableCols } from "@app/lib/knex";
import {
EnrollmentType,
IssuerType,
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
@@ -198,6 +199,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
slug: result.slug,
description: result.description,
enrollmentType: result.enrollmentType as EnrollmentType,
issuerType: result.issuerType as IssuerType,
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId,
@@ -239,12 +241,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
limit?: number;
search?: string;
enrollmentType?: EnrollmentType;
issuerType?: IssuerType;
caId?: string;
} = {},
tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithConfigs[]> => {
try {
const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
const { offset = 0, limit = 20, search, enrollmentType, issuerType, caId } = options;
let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`,
@@ -269,6 +272,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
}
if (issuerType) {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.issuerType`, issuerType);
}
const query = baseQuery
.leftJoin(
TableName.PkiEstEnrollmentConfig,
@@ -338,8 +345,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
slug: result.slug,
description: result.description,
enrollmentType: result.enrollmentType as EnrollmentType,
issuerType: result.issuerType as IssuerType,
estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
estConfig,
@@ -359,12 +368,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
options: {
search?: string;
enrollmentType?: EnrollmentType;
issuerType?: IssuerType;
caId?: string;
} = {},
tx?: Knex
): Promise<number> => {
try {
const { search, enrollmentType, caId } = options;
const { search, enrollmentType, issuerType, caId } = options;
let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId });
@@ -384,6 +394,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
query = query.where({ caId });
}
if (issuerType) {
query = query.where({ issuerType });
}
const result = await query.count("*").first();
return parseInt((result as unknown as { count: string }).count || "0", 10);
} catch (error) {
@@ -1,12 +1,17 @@
import RE2 from "re2";
import { z } from "zod";
import { EnrollmentType } from "./certificate-profile-types";
import { CertStatus } from "../certificate/certificate-types";
import { EnrollmentType, IssuerType } from "./certificate-profile-types";
export const createCertificateProfileSchema = z
.object({
projectId: z.string().uuid("Project ID must be valid"),
caId: z.string().uuid(),
caId: z
.union([z.string().uuid(), z.literal("")])
.optional()
.nullable()
.transform((val) => (val === "" ? null : val)),
certificateTemplateId: z.string().uuid(),
slug: z
.string()
@@ -15,6 +20,7 @@ export const createCertificateProfileSchema = z
.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),
issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
@@ -32,6 +38,7 @@ export const createCertificateProfileSchema = z
})
.refine(
(data) => {
// Validate enrollment type configurations
if (data.enrollmentType === EnrollmentType.EST) {
if (!data.estConfig) {
return false;
@@ -65,11 +72,26 @@ export const createCertificateProfileSchema = z
return false;
}
}
if (data.issuerType === IssuerType.CA) {
if (!data.caId) {
return false;
}
}
if (data.issuerType === IssuerType.SELF_SIGNED) {
if (data.caId) {
return false;
}
if (data.enrollmentType !== EnrollmentType.API) {
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."
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration. CA issuer type requires a CA ID. Self-signed issuer type cannot have a CA ID and only supports API enrollment."
}
);
@@ -83,6 +105,7 @@ export const updateCertificateProfileSchema = z
.optional(),
description: z.string().max(1000).optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
issuerType: z.nativeEnum(IssuerType).optional(),
estConfig: z
.object({
disableBootstrapCaValidation: z.boolean().default(false),
@@ -109,10 +132,18 @@ export const updateCertificateProfileSchema = z
return false;
}
}
if (data.issuerType === IssuerType.SELF_SIGNED) {
if (data.enrollmentType && data.enrollmentType !== EnrollmentType.API) {
return false;
}
}
return true;
},
{
message: "Cannot have EST config with API enrollment type or API config with EST enrollment type."
message:
"Cannot have EST config with API enrollment type or API config with EST enrollment type. Self-signed issuer type only supports API enrollment."
}
);
@@ -131,6 +162,7 @@ export const listCertificateProfilesSchema = z.object({
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(),
issuerType: z.nativeEnum(IssuerType).optional(),
caId: z.string().uuid().optional()
});
@@ -142,6 +174,6 @@ export const listCertificatesByProfileSchema = z.object({
profileId: z.string().uuid(),
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20),
status: z.enum(["active", "expired", "revoked"]).optional(),
status: z.nativeEnum(CertStatus).optional(),
search: z.string().optional()
});
@@ -22,7 +22,12 @@ import type { TKmsServiceFactory } from "../kms/kms-service";
import type { TProjectDALFactory } from "../project/project-dal";
import type { TCertificateProfileDALFactory } from "./certificate-profile-dal";
import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service";
import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types";
import {
EnrollmentType,
IssuerType,
TCertificateProfile,
TCertificateProfileWithConfigs
} from "./certificate-profile-types";
vi.mock("@app/lib/crypto/cryptography", () => ({
crypto: {
@@ -90,6 +95,7 @@ describe("CertificateProfileService", () => {
description: "Test certificate profile",
slug: "test-profile",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfigId: "api-config-123",
@@ -272,6 +278,7 @@ describe("CertificateProfileService", () => {
slug: "new-profile",
description: "New test profile",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -312,6 +319,7 @@ describe("CertificateProfileService", () => {
slug: "new-profile",
description: "New test profile",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfigId: "api-config-123",
@@ -383,6 +391,7 @@ describe("CertificateProfileService", () => {
slug: "invalid-profile",
description: "Invalid test profile",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123"
};
@@ -401,6 +410,7 @@ describe("CertificateProfileService", () => {
slug: "api-profile",
description: "Profile with API enrollment",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -726,6 +736,7 @@ describe("CertificateProfileService", () => {
slug: "est-profile",
description: "Profile with EST enrollment",
enrollmentType: EnrollmentType.EST,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
estConfig: {
@@ -776,6 +787,7 @@ describe("CertificateProfileService", () => {
slug: "different-profile-name",
description: "Profile with duplicate slug",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -801,6 +813,7 @@ describe("CertificateProfileService", () => {
slug: "auto-renew-profile",
description: "Profile with auto-renewal",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -965,6 +978,7 @@ describe("CertificateProfileService", () => {
slug: "invalid-template-profile",
description: "Profile with invalid template",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "nonexistent-template",
apiConfig: {
@@ -990,6 +1004,7 @@ describe("CertificateProfileService", () => {
slug: "concurrent-profile",
description: "Profile created concurrently",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -1018,6 +1033,7 @@ describe("CertificateProfileService", () => {
slug: "cross-project-profile",
description: "Profile using template from different project",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-456",
apiConfig: {
@@ -1047,6 +1063,7 @@ describe("CertificateProfileService", () => {
slug: "invalid-slug-profile",
description: "Profile with invalid slug format",
enrollmentType: EnrollmentType.API,
issuerType: IssuerType.CA,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
@@ -32,6 +32,7 @@ import { getProjectKmsCertificateKeyId } from "../project/project-fns";
import { TCertificateProfileDALFactory } from "./certificate-profile-dal";
import {
EnrollmentType,
IssuerType,
TCertificateProfile,
TCertificateProfileCertificate,
TCertificateProfileInsert,
@@ -39,6 +40,34 @@ import {
TCertificateProfileWithConfigs
} from "./certificate-profile-types";
const validateIssuerTypeConstraints = (
issuerType: IssuerType,
enrollmentType: EnrollmentType,
caId: string | null,
existingCaId?: string | null
) => {
if (issuerType === IssuerType.CA) {
if (!caId && !existingCaId) {
throw new ForbiddenRequestError({
message: "CA issuer type requires a Certificate Authority to be selected"
});
}
}
if (issuerType === IssuerType.SELF_SIGNED) {
if (caId) {
throw new ForbiddenRequestError({
message: "Self-signed issuer type cannot have a Certificate Authority"
});
}
if (enrollmentType !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Self-signed issuer type only supports API enrollment"
});
}
}
};
const generateAndEncryptAcmeEabSecret = async (
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
@@ -163,7 +192,8 @@ export type TCertificateProfileServiceFactory = ReturnType<typeof certificatePro
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
return {
...dalResult,
enrollmentType: dalResult.enrollmentType as EnrollmentType
enrollmentType: dalResult.enrollmentType as EnrollmentType,
issuerType: dalResult.issuerType as IssuerType
} as TCertificateProfile;
};
@@ -240,6 +270,8 @@ export const certificateProfileServiceFactory = ({
});
}
validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null);
// Validate enrollment configuration requirements
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
throw new ForbiddenRequestError({
@@ -376,7 +408,18 @@ export const certificateProfileServiceFactory = ({
}
}
const { estConfig, apiConfig, ...profileUpdateData } = data;
const finalIssuerType = data.issuerType || existingProfile.issuerType;
const finalEnrollmentType = data.enrollmentType || existingProfile.enrollmentType;
const finalCaId = data.caId !== undefined ? data.caId : existingProfile.caId;
validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId);
const updatedData =
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId && data.issuerType === IssuerType.SELF_SIGNED
? { ...data, caId: null }
: data;
const { estConfig, apiConfig, ...profileUpdateData } = updatedData;
const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
if (estConfig && existingProfile.estConfigId) {
@@ -569,6 +612,7 @@ export const certificateProfileServiceFactory = ({
limit = 20,
search,
enrollmentType,
issuerType,
caId
}: {
actor: ActorType;
@@ -580,6 +624,7 @@ export const certificateProfileServiceFactory = ({
limit?: number;
search?: string;
enrollmentType?: EnrollmentType;
issuerType?: IssuerType;
caId?: string;
}): Promise<{
profiles: TCertificateProfileWithConfigs[];
@@ -603,12 +648,14 @@ export const certificateProfileServiceFactory = ({
limit,
search,
enrollmentType,
issuerType,
caId
});
const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
search,
enrollmentType,
issuerType,
caId
});
@@ -10,16 +10,24 @@ export enum EnrollmentType {
ACME = "acme"
}
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & {
export enum IssuerType {
CA = "ca",
SELF_SIGNED = "self-signed"
}
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType"> & {
enrollmentType: EnrollmentType;
issuerType: IssuerType;
};
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType"> & {
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType" | "issuerType"> & {
enrollmentType: EnrollmentType;
issuerType: IssuerType;
};
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType"> & {
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType" | "issuerType"> & {
enrollmentType?: EnrollmentType;
issuerType?: IssuerType;
estConfig?: {
disableBootstrapCaValidation?: boolean;
passphrase?: string;