PKI revamp: general improvements

This commit is contained in:
Carlos Monastyrski
2025-10-17 15:47:41 -03:00
parent 4f23e6dc53
commit e195afba11
34 changed files with 601 additions and 579 deletions
@@ -7,6 +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 { CertStatus } from "@app/services/certificate/certificate-types";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
@@ -453,7 +454,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
querystring: z.object({ querystring: z.object({
offset: z.coerce.number().min(0).default(0), offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20), 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() search: z.string().optional()
}), }),
response: { response: {
@@ -54,8 +54,12 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
commonName: validateTemplateRegexField, commonName: validateTemplateRegexField.optional(),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
@@ -162,8 +166,12 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
body: z body: z
.object({ .object({
profileId: z.string().uuid(), profileId: z.string().uuid(),
csr: z.string().trim().min(1).max(4096), csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional() notAfter: validateCaDateField.optional()
}) })
@@ -234,11 +242,19 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
.array( .array(
z.object({ z.object({
type: z.nativeEnum(ACMESANType), type: z.nativeEnum(ACMESANType),
value: z.string() value: z
.string()
.trim()
.min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters")
}) })
) )
.min(1), .min(1, "At least one subject alternative name must be provided"),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
@@ -32,6 +32,7 @@ import {
CertExtendedKeyUsageOIDToName, CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm, CertKeyAlgorithm,
CertKeyUsage, CertKeyUsage,
CertSignatureAlgorithm,
CertStatus, CertStatus,
TAltNameMapping TAltNameMapping
} from "../../certificate/certificate-types"; } from "../../certificate/certificate-types";
@@ -1289,12 +1290,19 @@ export const internalCertificateAuthorityServiceFactory = ({
const caKeyAlgorithm = ca.internalCa.keyAlgorithm; const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0]; const requestedKeyType = signatureAlgorithm.split("-")[0];
const isRsaCa = caKeyAlgorithm.startsWith("RSA"); const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]);
const isEcdsaCa = caKeyAlgorithm.startsWith("EC"); const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]);
if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) { if (
(requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) ||
(requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa)
) {
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "ECDSA" : "unknown"; const supportedType = isRsaCa
? CertSignatureAlgorithm.RSA_SHA256.split("-")[0]
: isEcdsaCa
? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0]
: "unknown";
throw new BadRequestError({ 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.` message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
}); });
@@ -1655,12 +1663,19 @@ export const internalCertificateAuthorityServiceFactory = ({
const caKeyAlgorithm = ca.internalCa.keyAlgorithm; const caKeyAlgorithm = ca.internalCa.keyAlgorithm;
const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA)
const isRsaCa = caKeyAlgorithm.startsWith("RSA"); const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]);
const isEcdsaCa = caKeyAlgorithm.startsWith("EC"); const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]);
if ((requestedKeyType === "RSA" && !isRsaCa) || (requestedKeyType === "ECDSA" && !isEcdsaCa)) { if (
(requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) ||
(requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa)
) {
// eslint-disable-next-line no-nested-ternary // eslint-disable-next-line no-nested-ternary
const supportedType = isRsaCa ? "RSA" : isEcdsaCa ? "ECDSA" : "unknown"; const supportedType = isRsaCa
? CertSignatureAlgorithm.RSA_SHA256.split("-")[0]
: isEcdsaCa
? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0]
: "unknown";
throw new BadRequestError({ 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.` message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.`
}); });
@@ -559,7 +559,6 @@ describe("CertificateProfileService", () => {
expect(result).toEqual(sampleProfile); expect(result).toEqual(sampleProfile);
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.isProfileInUse).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123");
}); });
@@ -573,18 +572,6 @@ describe("CertificateProfileService", () => {
}) })
).rejects.toThrow(NotFoundError); ).rejects.toThrow(NotFoundError);
}); });
it("should throw ForbiddenRequestError when profile is in use", async () => {
(mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(true);
await expect(
service.deleteProfile({
...mockActor,
profileId: "profile-123"
})
).rejects.toThrow(ForbiddenRequestError);
expect(mockCertificateProfileDAL.deleteById).not.toHaveBeenCalled();
});
}); });
describe("getProfileCertificates", () => { describe("getProfileCertificates", () => {
@@ -841,23 +828,8 @@ describe("CertificateProfileService", () => {
expect(result.enrollmentType).toBe(EnrollmentType.EST); expect(result.enrollmentType).toBe(EnrollmentType.EST);
}); });
it("should prevent deletion of profiles with active certificates", async () => { it("should allow deletion of profiles", async () => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(true);
await expect(
service.deleteProfile({
...mockActor,
profileId: "profile-123"
})
).rejects.toThrow(ForbiddenRequestError);
expect(mockCertificateProfileDAL.deleteById).not.toHaveBeenCalled();
});
it("should allow deletion of unused profiles", async () => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false);
(mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile);
const result = await service.deleteProfile({ const result = await service.deleteProfile({
@@ -866,7 +838,6 @@ describe("CertificateProfileService", () => {
}); });
expect(result).toEqual(sampleProfile); expect(result).toEqual(sampleProfile);
expect(mockCertificateProfileDAL.isProfileInUse).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123");
}); });
}); });
@@ -116,7 +116,7 @@ export const certificateProfileServiceFactory = ({
const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId); const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId);
if (existingSlugProfile) { if (existingSlugProfile) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Certificate profile with this slug already exists in project" message: "Certificate profile with this name already exists in project"
}); });
} }
@@ -245,7 +245,7 @@ export const certificateProfileServiceFactory = ({
); );
if (conflictingProfile && conflictingProfile.id !== profileId) { if (conflictingProfile && conflictingProfile.id !== profileId) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Certificate profile with this slug already exists in project" message: "Certificate profile with this name already exists in project"
}); });
} }
} }
@@ -521,14 +521,6 @@ export const certificateProfileServiceFactory = ({
ProjectPermissionSub.CertificateProfiles ProjectPermissionSub.CertificateProfiles
); );
// Check if profile is in use by any certificates
const isInUse = await certificateProfileDAL.isProfileInUse(profileId);
if (isInUse) {
throw new ForbiddenRequestError({
message: "Cannot delete certificate profile that has issued certificates"
});
}
const deletedProfile = await certificateProfileDAL.deleteById(profileId); const deletedProfile = await certificateProfileDAL.deleteById(profileId);
if (!deletedProfile) { if (!deletedProfile) {
throw new NotFoundError({ message: "Failed to delete certificate profile" }); throw new NotFoundError({ message: "Failed to delete certificate profile" });
@@ -1,3 +1,4 @@
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -13,9 +14,9 @@ const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType);
const templateV2SubjectSchema = z const templateV2SubjectSchema = z
.object({ .object({
type: attributeTypeSchema, type: attributeTypeSchema,
allowed: z.array(z.string()).optional(), allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string()).optional(), required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string()).optional() denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -68,9 +69,9 @@ const templateV2ExtendedKeyUsagesSchema = z
const templateV2SanSchema = z const templateV2SanSchema = z
.object({ .object({
type: sanTypeSchema, type: sanTypeSchema,
allowed: z.array(z.string()).optional(), allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
required: z.array(z.string()).optional(), required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(),
denied: z.array(z.string()).optional() denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -87,22 +88,33 @@ const templateV2SanSchema = z
const templateV2ValiditySchema = z.object({ const templateV2ValiditySchema = z.object({
max: z max: z
.string() .string()
.regex(/^\d+[dhmy]$/, { .regex(new RE2("^\\d+[dhmy]$"), {
message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" message: "Max validity must be in format like '365d', '12m', '1y', or '24h'"
}) })
.optional() .optional()
}); });
const templateV2AlgorithmsSchema = z.object({ const templateV2AlgorithmsSchema = z.object({
signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), signature: z
keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() .array(z.string().trim().min(1, "Algorithm cannot be empty"))
.min(1, "At least one signature algorithm must be provided")
.optional(),
keyAlgorithm: z
.array(z.string().trim().min(1, "Algorithm cannot be empty"))
.min(1, "At least one key algorithm must be provided")
.optional()
}); });
export const certificateTemplateV2ResponseSchema = z.object({ export const certificateTemplateV2ResponseSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
projectId: z.string().uuid("Project ID must be valid"), projectId: z.string().uuid("Project ID must be valid"),
name: z.string(), name: z
description: z.string().nullable().optional(), .string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"),
description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(),
subject: z.array(templateV2SubjectSchema).optional(), subject: z.array(templateV2SubjectSchema).optional(),
sans: z.array(templateV2SanSchema).optional(), sans: z.array(templateV2SanSchema).optional(),
keyUsages: templateV2KeyUsagesSchema.optional(), keyUsages: templateV2KeyUsagesSchema.optional(),
@@ -114,26 +126,53 @@ export const certificateTemplateV2ResponseSchema = z.object({
}); });
export const certificateRequestSchema = z.object({ export const certificateRequestSchema = z.object({
commonName: z.string().optional(), commonName: z
organization: z.string().optional(), .string()
country: z.string().optional(), .trim()
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).optional(), .min(1, "Common name cannot be empty")
extendedKeyUsages: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), .max(64, "Common name must be less than 64 characters")
.optional(),
organization: z
.string()
.trim()
.min(1, "Organization cannot be empty")
.max(64, "Organization must be less than 64 characters")
.optional(),
country: z
.string()
.trim()
.min(2, "Country code must be 2 characters")
.max(2, "Country code must be 2 characters")
.optional(),
keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(),
extendedKeyUsages: z
.array(z.nativeEnum(CertExtendedKeyUsageType))
.min(1, "At least one extended key usage must be provided")
.optional(),
subjectAlternativeNames: z subjectAlternativeNames: z
.array( .array(
z.object({ z.object({
type: sanTypeSchema, type: sanTypeSchema,
value: z.string() value: z
.string()
.trim()
.min(1, "SAN value cannot be empty")
.max(255, "SAN value must be less than 255 characters")
}) })
) )
.min(1, "At least one SAN must be provided")
.optional(), .optional(),
validity: z validity: z
.object({ .object({
ttl: z.string() ttl: z
.string()
.trim()
.min(1, "TTL cannot be empty")
.regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'")
}) })
.optional(), .optional(),
signatureAlgorithm: z.string().optional(), signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(),
keyAlgorithm: z.string().optional() keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional()
}); });
export const validateCertificateRequestSchema = z.object({ export const validateCertificateRequestSchema = z.object({
@@ -8,6 +8,7 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
export enum CertStatus { export enum CertStatus {
ACTIVE = "active", ACTIVE = "active",
EXPIRED = "expired",
REVOKED = "revoked" REVOKED = "revoked"
} }
@@ -1,10 +0,0 @@
---
title: "Create"
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>
@@ -1,10 +0,0 @@
---
title: "Delete"
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>
@@ -1,10 +0,0 @@
---
title: "Get by ID"
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>
@@ -1,10 +0,0 @@
---
title: "Update"
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>
@@ -1,10 +0,0 @@
---
title: "Create"
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>
@@ -1,10 +0,0 @@
---
title: "Delete"
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>
@@ -1,10 +0,0 @@
---
title: "Retrieve 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>
@@ -1,10 +0,0 @@
---
title: "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>
@@ -1,10 +0,0 @@
---
title: "List 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>
@@ -1,10 +0,0 @@
---
title: "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>
@@ -1,10 +0,0 @@
---
title: "Retrieve"
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>
@@ -1,10 +0,0 @@
---
title: "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>
@@ -1,10 +0,0 @@
---
title: "Update"
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>
+1 -25
View File
@@ -2451,20 +2451,6 @@
{ {
"group": "Infisical PKI", "group": "Infisical PKI",
"pages": [ "pages": [
{
"group": "Subscribers",
"pages": [
"api-reference/endpoints/pki/subscribers/list-certs",
"api-reference/endpoints/pki/subscribers/create",
"api-reference/endpoints/pki/subscribers/read",
"api-reference/endpoints/pki/subscribers/update",
"api-reference/endpoints/pki/subscribers/delete",
"api-reference/endpoints/pki/subscribers/issue-cert",
"api-reference/endpoints/pki/subscribers/sign-cert",
"api-reference/endpoints/pki/subscribers/order-cert",
"api-reference/endpoints/pki/subscribers/get-latest-cert-bundle"
]
},
{ {
"group": "Certificate Authorities", "group": "Certificate Authorities",
"pages": [ "pages": [
@@ -2525,17 +2511,7 @@
"api-reference/endpoints/certificate-templates-v2/create", "api-reference/endpoints/certificate-templates-v2/create",
"api-reference/endpoints/certificate-templates-v2/update", "api-reference/endpoints/certificate-templates-v2/update",
"api-reference/endpoints/certificate-templates-v2/get-by-id", "api-reference/endpoints/certificate-templates-v2/get-by-id",
"api-reference/endpoints/certificate-templates-v2/delete", "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"
]
}
] ]
}, },
{ {
@@ -129,8 +129,7 @@ export enum ProjectPermissionCertificateProfileActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
IssueCert = "issue-cert", IssueCert = "issue-cert"
ListCerts = "list-certs"
} }
export enum ProjectPermissionSecretRotationActions { export enum ProjectPermissionSecretRotationActions {
@@ -226,7 +225,6 @@ 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
+4
View File
@@ -166,6 +166,10 @@ export const useCreateCertificateV3 = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug) queryKey: projectKeys.forProjectCertificates(projectSlug)
}); });
queryClient.invalidateQueries({
queryKey: ["certificate-profiles"]
});
} }
}); });
}; };
+6 -2
View File
@@ -176,7 +176,7 @@ export type TCreateCertificateV3DTO = {
profileId: string; profileId: string;
pkiCollectionId?: string; pkiCollectionId?: string;
friendlyName?: string; friendlyName?: string;
commonName: string; commonName?: string;
organization?: string; organization?: string;
organizationUnit?: string; organizationUnit?: string;
locality?: string; locality?: string;
@@ -195,7 +195,11 @@ export type TCreateCertificateV3DTO = {
keyAlgorithm?: string; keyAlgorithm?: string;
}; };
export type TCreateCertificateV3Response = TCreateCertificateResponse; export type TCreateCertificateV3Response = TCreateCertificateResponse & {
projectId: string;
profileName: string;
certificateId: string;
};
export type TOrderCertificateDTO = { export type TOrderCertificateDTO = {
projectSlug: string; projectSlug: string;
@@ -52,6 +52,10 @@ export const useRevokeCert = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
}); });
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
} }
}); });
}; };
@@ -12,8 +12,7 @@ import {
Modal, Modal,
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem
Switch
// DatePicker // DatePicker
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
@@ -152,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
type: CaType.INTERNAL, type: CaType.INTERNAL,
name: "", name: "",
status: CaStatus.ACTIVE, status: CaStatus.ACTIVE,
enableDirectIssuance: true, enableDirectIssuance: false,
configuration: { configuration: {
type: InternalCaType.ROOT, type: InternalCaType.ROOT,
organization: "", organization: "",
@@ -456,23 +455,6 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
name="enableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="enable-direct-issuance"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Enable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -27,10 +27,8 @@ import { useProject } from "@app/context";
import { useCreateCertificateV3, useGetCert } from "@app/hooks/api"; import { useCreateCertificateV3, useGetCert } from "@app/hooks/api";
import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { import {
certKeyAlgorithms,
EXTENDED_KEY_USAGES_OPTIONS, EXTENDED_KEY_USAGES_OPTIONS,
KEY_USAGES_OPTIONS, KEY_USAGES_OPTIONS
SIGNATURE_ALGORITHMS_OPTIONS
} from "@app/hooks/api/certificates/constants"; } from "@app/hooks/api/certificates/constants";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
@@ -42,16 +40,26 @@ import {
import { CertificateContent } from "./CertificateContent"; import { CertificateContent } from "./CertificateContent";
const schema = z.object({ const createSchema = (shouldShowSubjectSection: boolean) => {
return z.object({
profileId: z.string().min(1, "Profile is required"), profileId: z.string().min(1, "Profile is required"),
subjectAttributes: z subjectAttributes: shouldShowSubjectSection
? z
.array( .array(
z.object({ z.object({
type: z.enum(["common_name"]), type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required") value: z.string().min(1, "Value is required")
}) })
) )
.min(1, "At least one subject attribute is required"), .min(1, "At least one subject attribute is required")
: z
.array(
z.object({
type: z.enum(["common_name"]),
value: z.string().min(1, "Value is required")
})
)
.optional(),
subjectAltNames: z subjectAltNames: z
.array( .array(
z.object({ z.object({
@@ -61,8 +69,8 @@ const schema = z.object({
) )
.default([]), .default([]),
ttl: z.string().trim().min(1, "TTL is required"), ttl: z.string().trim().min(1, "TTL is required"),
signatureAlgorithm: z.string().optional(), signatureAlgorithm: z.string().min(1, "Signature algorithm is required"),
keyAlgorithm: z.string().optional(), keyAlgorithm: z.string().min(1, "Key algorithm is required"),
keyUsages: z.object({ keyUsages: z.object({
[CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
[CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
@@ -82,9 +90,10 @@ const schema = z.object({
[CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
[CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
}) })
}); });
};
export type FormData = z.infer<typeof schema>; export type FormData = z.infer<ReturnType<typeof createSchema>>;
type Props = { type Props = {
popUp: UsePopUpState<["certificateIssuance"]>; popUp: UsePopUpState<["certificateIssuance"]>;
@@ -110,6 +119,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState<string[]>([]); const [requiredExtendedKeyUsages, setRequiredExtendedKeyUsages] = useState<string[]>([]);
const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]); const [allowedSignatureAlgorithms, setAllowedSignatureAlgorithms] = useState<string[]>([]);
const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]); const [allowedKeyAlgorithms, setAllowedKeyAlgorithms] = useState<string[]>([]);
const [allowedSanTypes, setAllowedSanTypes] = useState<string[]>(["dns", "ip", "email", "uri"]);
const [shouldShowSanSection, setShouldShowSanSection] = useState<boolean>(true);
const [shouldShowSubjectSection, setShouldShowSubjectSection] = useState<boolean>(true);
const { currentProject } = useProject(); const { currentProject } = useProject();
const inputSerialNumber = const inputSerialNumber =
@@ -133,10 +145,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setValue, setValue,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<FormData>({ } = useForm<FormData>({
resolver: zodResolver(schema), resolver: zodResolver(createSchema(shouldShowSubjectSection)),
defaultValues: { defaultValues: {
profileId: profileId || "", profileId: profileId || "",
subjectAttributes: [{ type: "common_name", value: "" }], subjectAttributes: shouldShowSubjectSection
? [{ type: "common_name", value: "" }]
: undefined,
subjectAltNames: [], subjectAltNames: [],
ttl: "30d", ttl: "30d",
signatureAlgorithm: "", signatureAlgorithm: "",
@@ -154,6 +168,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setRequiredExtendedKeyUsages([]); setRequiredExtendedKeyUsages([]);
setAllowedSignatureAlgorithms([]); setAllowedSignatureAlgorithms([]);
setAllowedKeyAlgorithms([]); setAllowedKeyAlgorithms([]);
setAllowedSanTypes(["dns", "ip", "email", "uri"]);
setShouldShowSanSection(true);
setShouldShowSubjectSection(true);
reset(); reset();
}, [reset]); }, [reset]);
@@ -168,21 +185,31 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}); });
const filteredKeyUsages = useMemo(() => { const filteredKeyUsages = useMemo(() => {
if (allowedKeyUsages.length === 0) return KEY_USAGES_OPTIONS;
return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value)); return KEY_USAGES_OPTIONS.filter(({ value }) => allowedKeyUsages.includes(value));
}, [allowedKeyUsages]); }, [allowedKeyUsages]);
const filteredExtendedKeyUsages = useMemo(() => { const filteredExtendedKeyUsages = useMemo(() => {
if (allowedExtendedKeyUsages.length === 0) return EXTENDED_KEY_USAGES_OPTIONS;
return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) =>
allowedExtendedKeyUsages.includes(value) allowedExtendedKeyUsages.includes(value)
); );
}, [allowedExtendedKeyUsages]); }, [allowedExtendedKeyUsages]);
const availableSignatureAlgorithms = useMemo(() => { const mapBackendSanTypeToFrontend = (backendType: string): string => {
if (allowedSignatureAlgorithms.length === 0) { switch (backendType) {
return SIGNATURE_ALGORITHMS_OPTIONS; case "dns_name":
return "dns";
case "ip_address":
return "ip";
case "email":
return "email";
case "uri":
return "uri";
default:
return backendType;
} }
};
const availableSignatureAlgorithms = useMemo(() => {
return allowedSignatureAlgorithms.map((templateAlgorithm) => { return allowedSignatureAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm);
return { return {
@@ -193,9 +220,6 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}, [allowedSignatureAlgorithms]); }, [allowedSignatureAlgorithms]);
const availableKeyAlgorithms = useMemo(() => { const availableKeyAlgorithms = useMemo(() => {
if (allowedKeyAlgorithms.length === 0) {
return certKeyAlgorithms;
}
return allowedKeyAlgorithms.map((templateAlgorithm) => { return allowedKeyAlgorithms.map((templateAlgorithm) => {
const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm);
return { return {
@@ -247,6 +271,33 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setRequiredKeyUsages(templateData.keyUsages?.required || []); setRequiredKeyUsages(templateData.keyUsages?.required || []);
setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []); setRequiredExtendedKeyUsages(templateData.extendedKeyUsages?.required || []);
if (templateData.sans && templateData.sans.length > 0) {
const sanTypes: string[] = [];
templateData.sans.forEach((sanPolicy) => {
const frontendType = mapBackendSanTypeToFrontend(sanPolicy.type);
if (!sanTypes.includes(frontendType)) {
sanTypes.push(frontendType);
}
});
setAllowedSanTypes(sanTypes);
setShouldShowSanSection(true);
} else {
setAllowedSanTypes([]);
setShouldShowSanSection(false);
setValue("subjectAltNames", []);
}
if (templateData.subject && templateData.subject.length > 0) {
setShouldShowSubjectSection(true);
const currentSubjectAttrs = watch("subjectAttributes");
if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) {
setValue("subjectAttributes", [{ type: "common_name", value: "" }]);
}
} else {
setShouldShowSubjectSection(false);
setValue("subjectAttributes", undefined);
}
const initialKeyUsages: Record<string, boolean> = {}; const initialKeyUsages: Record<string, boolean> = {};
const initialExtendedKeyUsages: Record<string, boolean> = {}; const initialExtendedKeyUsages: Record<string, boolean> = {};
@@ -261,7 +312,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
setValue("keyUsages", initialKeyUsages); setValue("keyUsages", initialKeyUsages);
setValue("extendedKeyUsages", initialExtendedKeyUsages); setValue("extendedKeyUsages", initialExtendedKeyUsages);
} }
}, [templateData, selectedProfile, setValue, popUp?.certificateIssuance?.isOpen]); }, [templateData, selectedProfile, setValue, watch, popUp?.certificateIssuance?.isOpen]);
useEffect(() => { useEffect(() => {
if (cert) { if (cert) {
@@ -299,22 +350,19 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]); }, [popUp?.certificateIssuance?.isOpen, profileId, cert, setValue]);
const getAttributeValue = useCallback( const getAttributeValue = useCallback(
(subjectAttributes: typeof schema._type.subjectAttributes, type: string) => { (subjectAttributes: FormData["subjectAttributes"], type: string) => {
const foundAttr = subjectAttributes.find((attr) => attr.type === type); const foundAttr = subjectAttributes?.find((attr) => attr.type === type);
return foundAttr?.value || ""; return foundAttr?.value || "";
}, },
[] []
); );
const formatSubjectAltNames = useCallback( const formatSubjectAltNames = useCallback((subjectAltNames: FormData["subjectAltNames"]) => {
(subjectAltNames: typeof schema._type.subjectAltNames) => {
return subjectAltNames return subjectAltNames
.filter((san) => san.value.trim()) .filter((san) => san.value.trim())
.map((san) => san.value.trim()) .map((san) => san.value.trim())
.join(", "); .join(", ");
}, }, []);
[]
);
const filterUsages = useCallback(<T extends Record<string, boolean>>(usages: T) => { const filterUsages = useCallback(<T extends Record<string, boolean>>(usages: T) => {
return Object.entries(usages) return Object.entries(usages)
@@ -350,7 +398,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
return; return;
} }
const commonName = getAttributeValue(subjectAttributes, "common_name"); let commonName = "";
if (shouldShowSubjectSection && subjectAttributes && subjectAttributes.length > 0) {
commonName = getAttributeValue(subjectAttributes, "common_name");
if (!commonName.trim()) { if (!commonName.trim()) {
createNotification({ createNotification({
text: "Common name is required.", text: "Common name is required.",
@@ -358,20 +408,30 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}); });
return; return;
} }
}
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate( const certificateRequest: any = {
{
profileId: formProfileId, profileId: formProfileId,
projectSlug: currentProject.slug, projectSlug: currentProject.slug,
commonName,
subjectAltNames: formatSubjectAltNames(subjectAltNames),
ttl, ttl,
signatureAlgorithm, signatureAlgorithm,
keyAlgorithm, keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[], keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
if (shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
} }
); if (shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans) {
certificateRequest.subjectAltNames = formattedSans;
}
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({ setCertificateDetails({
serialNumber, serialNumber,
@@ -399,7 +459,8 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
[ [
currentProject?.slug, currentProject?.slug,
createCertificate, createCertificate,
reset, shouldShowSubjectSection,
shouldShowSanSection,
getAttributeValue, getAttributeValue,
formatSubjectAltNames, formatSubjectAltNames,
filterUsages filterUsages
@@ -517,6 +578,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
{(selectedProfile || profileId) && ( {(selectedProfile || profileId) && (
<> <>
{shouldShowSubjectSection && (
<Controller <Controller
control={control} control={control}
name="subjectAttributes" name="subjectAttributes"
@@ -528,13 +590,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
isError={Boolean(error)} isError={Boolean(error)}
> >
<div className="space-y-2"> <div className="space-y-2">
{value.map((attr, index) => ( {(value || []).map((attr, index) => (
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<div key={`subject-attr-${index}`} className="flex items-center gap-2"> <div key={`subject-attr-${index}`} className="flex items-center gap-2">
<Select <Select
value={attr.type} value={attr.type}
onValueChange={(newType) => { onValueChange={(newType) => {
const newValue = [...value]; const newValue = [...(value || [])];
newValue[index] = { newValue[index] = {
...attr, ...attr,
type: newType as typeof attr.type type: newType as typeof attr.type
@@ -548,20 +610,20 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<Input <Input
value={attr.value} value={attr.value}
onChange={(e) => { onChange={(e) => {
const newValue = [...value]; const newValue = [...(value || [])];
newValue[index] = { ...attr, value: e.target.value }; newValue[index] = { ...attr, value: e.target.value };
onChange(newValue); onChange(newValue);
}} }}
placeholder="example.com" placeholder="example.com"
className="flex-1" className="flex-1"
/> />
{value.length > 1 && ( {(value || []).length > 1 && (
<IconButton <IconButton
ariaLabel="Remove Subject Attribute" ariaLabel="Remove Subject Attribute"
variant="plain" variant="plain"
size="sm" size="sm"
onClick={() => { onClick={() => {
const newValue = value.filter((_, i) => i !== index); const newValue = (value || []).filter((_, i) => i !== index);
onChange(newValue); onChange(newValue);
}} }}
> >
@@ -576,7 +638,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
size="xs" size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
onChange([...value, { type: "common_name", value: "" }]); onChange([...(value || []), { type: "common_name", value: "" }]);
}} }}
className="w-full" className="w-full"
> >
@@ -586,7 +648,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</FormControl> </FormControl>
)} )}
/> />
)}
{shouldShowSanSection && (
<Controller <Controller
control={control} control={control}
name="subjectAltNames" name="subjectAltNames"
@@ -615,10 +679,18 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}} }}
className="w-24" className="w-24"
> >
{allowedSanTypes.includes("dns") && (
<SelectItem value="dns">DNS</SelectItem> <SelectItem value="dns">DNS</SelectItem>
)}
{allowedSanTypes.includes("ip") && (
<SelectItem value="ip">IP</SelectItem> <SelectItem value="ip">IP</SelectItem>
)}
{allowedSanTypes.includes("email") && (
<SelectItem value="email">Email</SelectItem> <SelectItem value="email">Email</SelectItem>
)}
{allowedSanTypes.includes("uri") && (
<SelectItem value="uri">URI</SelectItem> <SelectItem value="uri">URI</SelectItem>
)}
</Select> </Select>
<Input <Input
value={san.value} value={san.value}
@@ -649,7 +721,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
size="xs" size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
onChange([...value, { type: "dns", value: "" }]); const defaultType =
allowedSanTypes.length > 0 ? allowedSanTypes[0] : "dns";
onChange([
...value,
{ type: defaultType as "dns" | "ip" | "email" | "uri", value: "" }
]);
}} }}
className="w-full" className="w-full"
> >
@@ -659,6 +736,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</FormControl> </FormControl>
)} )}
/> />
)}
<Controller <Controller
control={control} control={control}
@@ -744,6 +822,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</div> </div>
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
{filteredKeyUsages.length > 0 && (
<AccordionItem value="key-usages"> <AccordionItem value="key-usages">
<AccordionTrigger>Key Usages</AccordionTrigger> <AccordionTrigger>Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
@@ -783,7 +862,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
)}
{filteredExtendedKeyUsages.length > 0 && (
<AccordionItem value="extended-key-usages"> <AccordionItem value="extended-key-usages">
<AccordionTrigger>Extended Key Usages</AccordionTrigger> <AccordionTrigger>Extended Key Usages</AccordionTrigger>
<AccordionContent> <AccordionContent>
@@ -823,6 +904,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
)}
</Accordion> </Accordion>
</> </>
)} )}
@@ -52,7 +52,7 @@ export const PoliciesPage = () => {
/> />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabSections)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabSections)}>
<TabList className="mb-6 w-full"> <TabList className="w-full">
<div className="flex w-full border-b border-mineshaft-600"> <div className="flex w-full border-b border-mineshaft-600">
<Tab value={TabSections.CertificateProfiles}>Certificate Profiles</Tab> <Tab value={TabSections.CertificateProfiles}>Certificate Profiles</Tab>
<Tab value={TabSections.CertificateTemplatesV2}>Certificate Templates</Tab> <Tab value={TabSections.CertificateTemplatesV2}>Certificate Templates</Tab>
@@ -27,8 +27,20 @@ import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplat
const createSchema = z const createSchema = z
.object({ .object({
slug: z.string().trim().min(1, "Profile slug is required"), slug: z
description: z.string().optional(), .string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]), enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), certificateAuthorityId: z.string().min(1, "Certificate Authority is required"),
certificateTemplateId: z.string().min(1, "Certificate Template is required"), certificateTemplateId: z.string().min(1, "Certificate Template is required"),
@@ -36,8 +48,19 @@ const createSchema = z
.object({ .object({
disableBootstrapCaValidation: z.boolean().optional(), disableBootstrapCaValidation: z.boolean().optional(),
passphrase: z.string().min(1, "EST passphrase is required"), passphrase: z.string().min(1, "EST passphrase is required"),
caChain: z.string().min(1, "EST CA chain is required") caChain: z.string().min(1, "EST CA chain is required").optional()
}) })
.refine(
(data) => {
if (!data.disableBootstrapCaValidation && !data.caChain) {
return false;
}
return true;
},
{
message: "EST CA chain is required"
}
)
.optional(), .optional(),
apiConfig: z apiConfig: z
.object({ .object({
@@ -63,8 +86,20 @@ const createSchema = z
const editSchema = z const editSchema = z
.object({ .object({
slug: z.string().trim().min(1, "Profile slug is required"), slug: z
description: z.string().optional(), .string()
.trim()
.min(1, "Profile slug is required")
.max(255, "Profile slug must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Profile slug must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
enrollmentType: z.enum(["api", "est"]), enrollmentType: z.enum(["api", "est"]),
certificateAuthorityId: z.string().optional(), certificateAuthorityId: z.string().optional(),
certificateTemplateId: z.string().optional(), certificateTemplateId: z.string().optional(),
@@ -136,7 +171,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
estConfig: { estConfig: {
disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false, disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false,
passphrase: "", passphrase: "",
caChain: "" caChain: undefined
}, },
apiConfig: { apiConfig: {
autoRenew: profile.apiConfig?.autoRenew || false, autoRenew: profile.apiConfig?.autoRenew || false,
@@ -193,7 +228,11 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
}; };
if (data.enrollmentType === "est" && data.estConfig) { if (data.enrollmentType === "est" && data.estConfig) {
createData.estConfig = data.estConfig; createData.estConfig = {
passphrase: data.estConfig.passphrase,
caChain: data.estConfig.caChain || "",
disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation
};
} else if (data.enrollmentType === "api" && data.apiConfig) { } else if (data.enrollmentType === "api" && data.apiConfig) {
createData.apiConfig = data.apiConfig; createData.apiConfig = data.apiConfig;
} }
@@ -241,7 +280,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="slug" name="slug"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Profile Slug" label="Name"
isRequired isRequired
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
@@ -150,7 +150,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
<Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700"> <Tr key={profile.id} className="h-10 transition-colors duration-100 hover:bg-mineshaft-700">
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium text-mineshaft-100">{profile.slug}</div> <div className="text-mineshaft-300">{profile.slug}</div>
{profile.description && ( {profile.description && (
<Tooltip content={profile.description}> <Tooltip content={profile.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
@@ -362,8 +362,8 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
return { return {
name: data.name, name: data.name,
description: data.description, description: data.description,
subject: subject.length > 0 ? subject : undefined, subject,
sans: sans.length > 0 ? sans : undefined, sans,
keyUsages: Object.keys(keyUsages).length > 0 ? keyUsages : undefined, keyUsages: Object.keys(keyUsages).length > 0 ? keyUsages : undefined,
extendedKeyUsages: Object.keys(extendedKeyUsages).length > 0 ? extendedKeyUsages : undefined, extendedKeyUsages: Object.keys(extendedKeyUsages).length > 0 ? extendedKeyUsages : undefined,
algorithms: Object.keys(algorithms).length > 0 ? algorithms : undefined, algorithms: Object.keys(algorithms).length > 0 ? algorithms : undefined,
@@ -614,7 +614,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
required required
/> />
{watchedAttributes.length > 1 && ( {watchedAttributes.length > 0 && (
<IconButton <IconButton
ariaLabel="Remove Attribute" ariaLabel="Remove Attribute"
variant="plain" variant="plain"
@@ -672,7 +672,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
}; };
setValue("subjectAlternativeNames", newSans); setValue("subjectAlternativeNames", newSans);
}} }}
className="w-24" className="w-36"
> >
{SAN_TYPE_OPTIONS.map((type) => ( {SAN_TYPE_OPTIONS.map((type) => (
<SelectItem key={type} value={type}> <SelectItem key={type} value={type}>
@@ -719,7 +719,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
required required
/> />
{watchedSans.length > 1 && ( {watchedSans.length > 0 && (
<IconButton <IconButton
ariaLabel="Remove SAN" ariaLabel="Remove SAN"
variant="plain" variant="plain"
@@ -755,7 +755,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200"> <h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Signature Algorithms Allowed Signature Algorithms
</h4> </h4>
<Controller <Controller
control={control} control={control}
@@ -799,7 +799,9 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create"
</div> </div>
<div> <div>
<h4 className="mb-3 text-sm font-medium text-mineshaft-200">Key Algorithms</h4> <h4 className="mb-3 text-sm font-medium text-mineshaft-200">
Allowed Key Algorithms
</h4>
<Controller <Controller
control={control} control={control}
name="keyAlgorithm.allowedKeyTypes" name="keyAlgorithm.allowedKeyTypes"
@@ -89,7 +89,7 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
> >
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium">{template.name}</div> <div className="text-mineshaft-300">{template.name}</div>
{template.description && ( {template.description && (
<Tooltip content={template.description}> <Tooltip content={template.description}>
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" /> <FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
@@ -50,8 +50,20 @@ export const uiKeyAlgorithmSchema = z.object({
}); });
export const templateSchema = z.object({ export const templateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"), name: z
description: z.string().optional(), .string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
attributes: z.array(uiAttributeSchema).optional(), attributes: z.array(uiAttributeSchema).optional(),
subjectAlternativeNames: z.array(uiSanSchema).optional(), subjectAlternativeNames: z.array(uiSanSchema).optional(),
keyUsages: uiKeyUsagesSchema.optional(), keyUsages: uiKeyUsagesSchema.optional(),
@@ -86,8 +98,20 @@ export const apiSanSchema = z
}); });
export const apiTemplateSchema = z.object({ export const apiTemplateSchema = z.object({
name: z.string().trim().min(1, "Template name is required"), name: z
description: z.string().optional(), .string()
.trim()
.min(1, "Template name is required")
.max(255, "Template name must be less than 255 characters")
.regex(
/^[a-zA-Z0-9-_]+$/,
"Template name must contain only letters, numbers, hyphens, and underscores"
),
description: z
.string()
.trim()
.max(1000, "Description must be less than 1000 characters")
.optional(),
subject: z.array(apiSubjectSchema).optional(), subject: z.array(apiSubjectSchema).optional(),
sans: z.array(apiSanSchema).optional(), sans: z.array(apiSanSchema).optional(),
keyUsages: z keyUsages: z
@@ -106,14 +130,15 @@ export const apiTemplateSchema = z.object({
.optional(), .optional(),
algorithms: z algorithms: z
.object({ .object({
signature: z.array(z.string()).optional(), signature: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional(),
keyAlgorithm: z.array(z.string()).optional() keyAlgorithm: z.array(z.string().trim().min(1, "Algorithm cannot be empty")).optional()
}) })
.optional(), .optional(),
validity: z validity: z
.object({ .object({
max: z max: z
.string() .string()
.trim()
.regex(/^[1-9]\d*[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'") .regex(/^[1-9]\d*[dhmy]$/, "Must be in format like '365d', '12m', '1y', or '24h'")
.optional() .optional()
}) })
@@ -393,12 +393,8 @@ export const projectRoleFormSchema = z.object({
}) })
.array() .array()
.default([]), .default([]),
[ProjectPermissionSub.CertificateProfiles]: CertificateProfilePolicyActionSchema.extend({ [ProjectPermissionSub.CertificateProfiles]:
inverted: z.boolean().optional(), CertificateProfilePolicyActionSchema.array().default([]),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default( [ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[] []
), ),
@@ -479,7 +475,6 @@ 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 ||
@@ -1176,9 +1171,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
), ),
[ProjectPermissionCertificateProfileActions.IssueCert]: action.includes( [ProjectPermissionCertificateProfileActions.IssueCert]: action.includes(
ProjectPermissionCertificateProfileActions.IssueCert ProjectPermissionCertificateProfileActions.IssueCert
), )
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
}); });
return; return;
@@ -2195,6 +2188,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
{ {
subject: ProjectPermissionSub.PkiSyncs, subject: ProjectPermissionSub.PkiSyncs,
actions: [ProjectPermissionPkiSyncActions.Read] actions: [ProjectPermissionPkiSyncActions.Read]
},
{
subject: ProjectPermissionSub.CertificateProfiles,
actions: [ProjectPermissionCertificateProfileActions.Read]
} }
] ]
}, },
@@ -2226,6 +2223,10 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
{ {
subject: ProjectPermissionSub.PkiSyncs, subject: ProjectPermissionSub.PkiSyncs,
actions: Object.values(ProjectPermissionPkiSyncActions) actions: Object.values(ProjectPermissionPkiSyncActions)
},
{
subject: ProjectPermissionSub.CertificateProfiles,
actions: Object.values(ProjectPermissionCertificateProfileActions)
} }
] ]
}, },