feat: initial enforcement of template policy

This commit is contained in:
Sheen Capadngan
2024-08-16 16:32:47 +08:00
parent 28a2a6c41a
commit b9bd518aa6
18 changed files with 427 additions and 37 deletions

View File

@@ -75,6 +75,7 @@
"pino": "^8.16.2",
"posthog-node": "^3.6.2",
"probot": "^13.0.0",
"safe-regex": "^2.1.1",
"smee-client": "^2.0.0",
"tedious": "^18.2.1",
"tweetnacl": "^1.0.3",
@@ -106,6 +107,7 @@
"@types/picomatch": "^2.3.3",
"@types/prompt-sync": "^4.2.3",
"@types/resolve": "^1.20.6",
"@types/safe-regex": "^1.1.6",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
@@ -9774,6 +9776,12 @@
"integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==",
"dev": true
},
"node_modules/@types/safe-regex": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@types/safe-regex/-/safe-regex-1.1.6.tgz",
"integrity": "sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==",
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
@@ -17778,6 +17786,14 @@
"@babel/runtime": "^7.8.4"
}
},
"node_modules/regexp-tree": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz",
"integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==",
"bin": {
"regexp-tree": "bin/regexp-tree"
}
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz",
@@ -18094,6 +18110,14 @@
}
]
},
"node_modules/safe-regex": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz",
"integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==",
"dependencies": {
"regexp-tree": "~0.1.1"
}
},
"node_modules/safe-regex-test": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",

View File

@@ -78,6 +78,7 @@
"@types/picomatch": "^2.3.3",
"@types/prompt-sync": "^4.2.3",
"@types/resolve": "^1.20.6",
"@types/safe-regex": "^1.1.6",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
@@ -171,6 +172,7 @@
"pino": "^8.16.2",
"posthog-node": "^3.6.2",
"probot": "^13.0.0",
"safe-regex": "^2.1.1",
"smee-client": "^2.0.0",
"tedious": "^18.2.1",
"tweetnacl": "^1.0.3",

View File

@@ -53,6 +53,9 @@ import {
TCertificateSecretsUpdate,
TCertificatesInsert,
TCertificatesUpdate,
TCertificateTemplates,
TCertificateTemplatesInsert,
TCertificateTemplatesUpdate,
TDynamicSecretLeases,
TDynamicSecretLeasesInsert,
TDynamicSecretLeasesUpdate,
@@ -318,11 +321,6 @@ import {
TWebhooksInsert,
TWebhooksUpdate
} from "@app/db/schemas";
import {
TCertificateTemplates,
TCertificateTemplatesInsert,
TCertificateTemplatesUpdate
} from "@app/db/schemas/certificate-templates";
import {
TSecretV2TagJunction,
TSecretV2TagJunctionInsert,

View File

@@ -14,6 +14,7 @@ export * from "./certificate-authority-crl";
export * from "./certificate-authority-secret";
export * from "./certificate-bodies";
export * from "./certificate-secrets";
export * from "./certificate-templates";
export * from "./certificates";
export * from "./dynamic-secret-leases";
export * from "./dynamic-secrets";

View File

@@ -1088,6 +1088,7 @@ export const CERTIFICATE_AUTHORITIES = {
},
ISSUE_CERT: {
caId: "The ID of the CA to issue the certificate from",
certificateTemplateId: "The ID of the certificate template to issue the certificate from",
friendlyName: "A friendly name for the certificate",
commonName: "The common name (CN) for the certificate",
altNames:

View File

@@ -622,6 +622,7 @@ export const registerRoutes = async (
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateTemplateDAL,
certificateAuthorityQueue,
certificateDAL,
certificateBodyDAL,

View File

@@ -1,12 +1,17 @@
import ms from "ms";
import { z } from "zod";
import { CertificatesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { CERTIFICATES } from "@app/lib/api-docs";
import { CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CrlReason } from "@app/services/certificate/certificate-types";
import {
validateAltNamesField,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";
export const registerCertRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -55,6 +60,183 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/issue-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Issue certificate",
body: z
.object({
caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId),
certificateTemplateId: z
.string()
.trim()
.optional()
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId),
friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName),
commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName),
altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter)
})
.refine(
(data) => {
const { ttl, notAfter } = data;
return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined);
},
{
message: "Either ttl or notAfter must be present, but not both",
path: ["ttl", "notAfter"]
}
)
.refine(
(data) =>
(data.caId !== undefined && data.certificateTemplateId === undefined) ||
(data.caId === undefined && data.certificateTemplateId !== undefined),
{
message: "Either CA ID or Certificate Template ID must be present, but not both",
path: ["caId", "certificateTemplateId"]
}
),
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate),
issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate),
certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain),
privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey),
serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber)
})
}
},
handler: async (req) => {
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } =
await server.services.certificateAuthority.issueCertFromCa({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: ca.projectId,
event: {
type: EventType.ISSUE_CERT,
metadata: {
caId: ca.id,
dn: ca.dn,
serialNumber
}
}
});
return {
certificate,
certificateChain,
issuingCaCertificate,
privateKey,
serialNumber
};
}
});
server.route({
method: "POST",
url: "/sign-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Sign certificate",
body: z
.object({
caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId),
certificateTemplateId: z
.string()
.trim()
.optional()
.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId),
csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr),
friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName),
commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName),
altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl),
notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore),
notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter)
})
.refine(
(data) => {
const { ttl, notAfter } = data;
return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined);
},
{
message: "Either ttl or notAfter must be present, but not both",
path: ["ttl", "notAfter"]
}
)
.refine(
(data) =>
(data.caId !== undefined && data.certificateTemplateId === undefined) ||
(data.caId === undefined && data.certificateTemplateId !== undefined),
{
message: "Either CA ID or Certificate Template ID must be present, but not both",
path: ["caId", "certificateTemplateId"]
}
),
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate),
issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate),
certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain),
serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber)
})
}
},
handler: async (req) => {
const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } =
await server.services.certificateAuthority.signCertFromCa({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: ca.projectId,
event: {
type: EventType.SIGN_CERT,
metadata: {
caId: ca.id,
dn: ca.dn,
serialNumber
}
}
});
return {
certificate,
certificateChain,
issuingCaCertificate,
serialNumber
};
}
});
server.route({
method: "POST",
url: "/:serialNumber/revoke",

View File

@@ -1,9 +1,11 @@
import ms from "ms";
import { z } from "zod";
import { CertificateTemplatesSchema } from "@app/db/schemas/certificate-templates";
import { CertificateTemplatesSchema } from "@app/db/schemas";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({
id: true,
@@ -58,9 +60,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
schema: {
body: z.object({
caId: z.string(),
name: z.string(),
commonName: z.string(),
ttl: z.string()
name: z.string().min(1),
commonName: validateTemplateRegexField,
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number")
}),
response: {
200: z.object({
@@ -91,9 +93,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid
schema: {
body: z.object({
caId: z.string().optional(),
name: z.string().optional(),
commonName: z.string().optional(),
ttl: z.string().optional()
name: z.string().min(1).optional(),
commonName: validateTemplateRegexField.optional(),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional()
}),
params: z.object({
certificateTemplateId: z.string()

View File

@@ -4,11 +4,11 @@ import { z } from "zod";
import {
CertificateAuthoritiesSchema,
CertificatesSchema,
CertificateTemplatesSchema,
PkiAlertsSchema,
PkiCollectionsSchema,
ProjectKeysSchema
} from "@app/db/schemas";
import { CertificateTemplatesSchema } from "@app/db/schemas/certificate-templates";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PROJECTS } from "@app/lib/api-docs";
import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
@@ -478,7 +478,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
name: true
}).merge(
z.object({
caName: z.string()
caName: z.string(),
caId: z.string()
})
)
)

View File

@@ -5,9 +5,10 @@ import crypto, { KeyObject } from "crypto";
import ms from "ms";
import { z } from "zod";
import { TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
@@ -16,6 +17,8 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types";
import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal";
import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns";
import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import {
@@ -54,6 +57,7 @@ type TCertificateAuthorityServiceFactoryDep = {
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "create" | "findOne" | "transaction" | "find">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "create" | "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "create" | "findOne" | "update">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getById">;
certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick
certificateDAL: Pick<TCertificateDALFactory, "transaction" | "create" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
@@ -69,6 +73,7 @@ export const certificateAuthorityServiceFactory = ({
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateTemplateDAL,
certificateDAL,
certificateBodyDAL,
projectDAL,
@@ -1001,6 +1006,7 @@ export const certificateAuthorityServiceFactory = ({
*/
const issueCertFromCa = async ({
caId,
certificateTemplateId,
friendlyName,
commonName,
altNames,
@@ -1012,8 +1018,25 @@ export const certificateAuthorityServiceFactory = ({
actor,
actorOrgId
}: TIssueCertFromCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
let ca: TCertificateAuthorities | undefined;
let certificateTemplate: TCertificateTemplates | undefined;
if (caId) {
ca = await certificateAuthorityDAL.findById(caId);
} else if (certificateTemplateId) {
certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId);
if (!certificateTemplate) {
throw new NotFoundError({
message: "Certificate template not found"
});
}
ca = await certificateAuthorityDAL.findById(certificateTemplate.caId);
}
if (!ca) {
throw new BadRequestError({ message: "CA not found" });
}
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -1073,6 +1096,17 @@ export const certificateAuthorityServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName,
notBeforeDate,
notAfterDate
},
certificateTemplate
);
}
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
@@ -1207,6 +1241,7 @@ export const certificateAuthorityServiceFactory = ({
*/
const signCertFromCa = async ({
caId,
certificateTemplateId,
csr,
friendlyName,
commonName,
@@ -1219,8 +1254,25 @@ export const certificateAuthorityServiceFactory = ({
actor,
actorOrgId
}: TSignCertFromCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
let ca: TCertificateAuthorities | undefined;
let certificateTemplate: TCertificateTemplates | undefined;
if (caId) {
ca = await certificateAuthorityDAL.findById(caId);
} else if (certificateTemplateId) {
certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId);
if (!certificateTemplate) {
throw new NotFoundError({
message: "Certificate template not found"
});
}
ca = await certificateAuthorityDAL.findById(certificateTemplate.caId);
}
if (!ca) {
throw new BadRequestError({ message: "CA not found" });
}
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -1293,6 +1345,17 @@ export const certificateAuthorityServiceFactory = ({
message: "A common name (CN) is required in the CSR or as a parameter to this endpoint"
});
if (certificateTemplate) {
validateCertificateDetailsAgainstTemplate(
{
commonName: cn,
notBeforeDate,
notAfterDate
},
certificateTemplate
);
}
const { caPrivateKey } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,

View File

@@ -86,7 +86,8 @@ export type TImportCertToCaDTO = {
} & Omit<TProjectPermission, "projectId">;
export type TIssueCertFromCaDTO = {
caId: string;
caId?: string;
certificateTemplateId?: string;
friendlyName?: string;
commonName: string;
altNames: string;
@@ -96,8 +97,9 @@ export type TIssueCertFromCaDTO = {
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO = {
caId: string;
caId?: string;
csr: string;
certificateTemplateId?: string;
friendlyName?: string;
commonName?: string;
altNames: string;

View File

@@ -0,0 +1,28 @@
import ms from "ms";
import { BadRequestError } from "@app/lib/errors";
export const validateCertificateDetailsAgainstTemplate = (
cert: {
commonName: string;
notBeforeDate: Date;
notAfterDate: Date;
},
template: {
commonName: string;
ttl: string;
}
) => {
const commonNameRegex = new RegExp(template.commonName);
if (!commonNameRegex.test(cert.commonName)) {
throw new BadRequestError({
message: "Invalid common name based on template policy"
});
}
if (cert.notAfterDate.getTime() - cert.notBeforeDate.getTime() > ms(template.ttl)) {
throw new BadRequestError({
message: "Invalid validity date based on template policy"
});
}
};

View File

@@ -0,0 +1,14 @@
import safe from "safe-regex";
import z from "zod";
export const validateTemplateRegexField = z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
})
// we ensure that the inputted pattern is computationally safe by limiting star height to 1
.refine((v) => safe(v), {
message: "Unsafe REGEX pattern"
});

View File

@@ -16,7 +16,8 @@ import {
TRenewCaResponse,
TSignIntermediateDTO,
TSignIntermediateResponse,
TUpdateCaDTO} from "./types";
TUpdateCaDTO
} from "./types";
export const useCreateCa = () => {
const queryClient = useQueryClient();
@@ -98,9 +99,9 @@ export const useImportCaCertificate = () => {
export const useCreateCertificate = () => {
const queryClient = useQueryClient();
return useMutation<TCreateCertificateResponse, {}, TCreateCertificateDTO>({
mutationFn: async ({ caId, ...body }) => {
mutationFn: async (body) => {
const { data } = await apiRequest.post<TCreateCertificateResponse>(
`/api/v1/pki/ca/${caId}/issue-certificate`,
"/api/v1/pki/certificates/issue-certificate",
body
);
return data;

View File

@@ -1,5 +1,5 @@
import { CertKeyAlgorithm } from "../certificates/enums";
import { CaRenewalType,CaStatus, CaType } from "./enums";
import { CaRenewalType, CaStatus, CaType } from "./enums";
export type TCertificateAuthority = {
id: string;
@@ -78,7 +78,8 @@ export type TImportCaCertificateResponse = {
export type TCreateCertificateDTO = {
projectSlug: string;
caId: string;
caId?: string;
certificateTemplateId?: string;
friendlyName?: string;
commonName: string;
altNames: string; // sans

View File

@@ -2,6 +2,7 @@ export type TCertificateTemplateListEntry = {
id: string;
name: string;
caName: string;
caId: string;
};
export type TCertificateTemplate = {

View File

@@ -14,13 +14,21 @@ import {
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api";
import {
CaStatus,
useCreateCertificate,
useGetCert,
useGetCertTemplate,
useListWorkspaceCas,
useListWorkspaceCertificateTemplates
} from "@app/hooks/api";
import { caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertificateContent } from "./CertificateContent";
const schema = z.object({
certificateTemplateId: z.string().optional(),
caId: z.string(),
friendlyName: z.string(),
commonName: z.string().trim().min(1),
@@ -42,6 +50,8 @@ type TCertificateDetails = {
privateKey: string;
};
const CERT_TEMPLATE_NONE_VALUE = "none";
export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
const { currentWorkspace } = useWorkspace();
@@ -54,6 +64,10 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
status: CaStatus.ACTIVE
});
const { data: templatesData } = useListWorkspaceCertificateTemplates({
workspaceId: currentWorkspace?.id || ""
});
const { mutateAsync: createCertificate } = useCreateCertificate();
const {
@@ -61,11 +75,20 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
handleSubmit,
reset,
formState: { isSubmitting },
setValue
setValue,
watch
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const selectedCertTemplateId = watch("certificateTemplateId");
const hasCertTemplateSelected =
selectedCertTemplateId !== "" && selectedCertTemplateId !== CERT_TEMPLATE_NONE_VALUE;
const { data: selectedCertTemplate } = useGetCertTemplate(
hasCertTemplateSelected ? (selectedCertTemplateId as string) : ""
);
useEffect(() => {
if (cert) {
reset({
@@ -81,7 +104,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
friendlyName: "",
commonName: "",
altNames: "",
ttl: ""
ttl: "",
certificateTemplateId: CERT_TEMPLATE_NONE_VALUE
});
}
}, [cert]);
@@ -91,8 +115,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
if (!currentWorkspace?.slug) return;
const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({
caId: !selectedCertTemplate ? caId : undefined,
certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined,
projectSlug: currentWorkspace.slug,
caId,
friendlyName,
commonName,
altNames,
@@ -141,11 +166,11 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="caId"
name="certificateTemplateId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
label="Certificate Template"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
@@ -158,15 +183,47 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
className="w-full"
isDisabled={Boolean(cert)}
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
<SelectItem value={CERT_TEMPLATE_NONE_VALUE} key="cert-template-none">
None
</SelectItem>
{(templatesData?.certificateTemplates || []).map(({ id, name }) => (
<SelectItem value={id} key={`cert-template-${id}`}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{(!selectedCertTemplateId || selectedCertTemplateId === CERT_TEMPLATE_NONE_VALUE) && (
<Controller
control={control}
name="caId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<Controller
control={control}
defaultValue=""

View File

@@ -27,7 +27,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
caId: z.string(),
name: z.string().min(1),
commonName: z.string().trim().min(1),
commonName: z
.string()
.trim()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, {
message:
"Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed."
}),
ttl: z.string().trim().min(1)
});