diff --git a/backend/package-lock.json b/backend/package-lock.json index 71d994f2e..18b1548f1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -76,6 +76,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", @@ -107,6 +108,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", @@ -9801,6 +9803,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", @@ -17821,6 +17829,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", @@ -18137,6 +18153,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", diff --git a/backend/package.json b/backend/package.json index 4ea217808..fbd02365b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", @@ -172,6 +173,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", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index da1d93bf7..e5c2e19e4 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -36,6 +36,7 @@ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; @@ -156,6 +157,7 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateTemplate: TCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; pkiCollection: TPkiCollectionServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 0dc781092..6a2cdfcb9 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -53,6 +53,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateTemplates, + TCertificateTemplatesInsert, + TCertificateTemplatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -364,6 +367,11 @@ declare module "knex/types/tables" { TCertificateAuthorityCrlUpdate >; [TableName.Certificate]: KnexOriginal.CompositeTableType; + [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< + TCertificateTemplates, + TCertificateTemplatesInsert, + TCertificateTemplatesUpdate + >; [TableName.CertificateBody]: KnexOriginal.CompositeTableType< TCertificateBodies, TCertificateBodiesInsert, diff --git a/backend/src/db/migrations/20240818184238_add-certificate-template.ts b/backend/src/db/migrations/20240818184238_add-certificate-template.ts new file mode 100644 index 000000000..7135c24e0 --- /dev/null +++ b/backend/src/db/migrations/20240818184238_add-certificate-template.ts @@ -0,0 +1,55 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const hasCertificateTemplateTable = await knex.schema.hasTable(TableName.CertificateTemplate); + if (!hasCertificateTemplateTable) { + await knex.schema.createTable(TableName.CertificateTemplate, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.uuid("caId").notNullable(); + tb.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + tb.uuid("pkiCollectionId"); + tb.foreign("pkiCollectionId").references("id").inTable(TableName.PkiCollection).onDelete("SET NULL"); + tb.string("name").notNullable(); + tb.string("commonName").notNullable(); + tb.string("subjectAlternativeName").notNullable(); + tb.string("ttl").notNullable(); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.CertificateTemplate); + } + + const doesCertificateTableHaveTemplateId = await knex.schema.hasColumn( + TableName.Certificate, + "certificateTemplateId" + ); + + if (!doesCertificateTableHaveTemplateId) { + await knex.schema.alterTable(TableName.Certificate, (tb) => { + tb.uuid("certificateTemplateId"); + tb.foreign("certificateTemplateId").references("id").inTable(TableName.CertificateTemplate).onDelete("SET NULL"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesCertificateTableHaveTemplateId = await knex.schema.hasColumn( + TableName.Certificate, + "certificateTemplateId" + ); + + if (doesCertificateTableHaveTemplateId) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("certificateTemplateId"); + }); + } + + const hasCertificateTemplateTable = await knex.schema.hasTable(TableName.CertificateTemplate); + if (hasCertificateTemplateTable) { + await knex.schema.dropTable(TableName.CertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.CertificateTemplate); + } +} diff --git a/backend/src/db/schemas/certificate-templates.ts b/backend/src/db/schemas/certificate-templates.ts new file mode 100644 index 000000000..6e1989195 --- /dev/null +++ b/backend/src/db/schemas/certificate-templates.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + caId: z.string().uuid(), + pkiCollectionId: z.string().uuid().nullable().optional(), + name: z.string(), + commonName: z.string(), + subjectAlternativeName: z.string(), + ttl: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TCertificateTemplates = z.infer; +export type TCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TCertificateTemplatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index cb14d05f9..7ef56e505 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -21,7 +21,8 @@ export const CertificatesSchema = z.object({ revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), altNames: z.string().default("").nullable().optional(), - caCertId: z.string().uuid() + caCertId: z.string().uuid(), + certificateTemplateId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 03146f204..bfa3a0a0e 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ebbead70c..03f7d6a3b 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -9,6 +9,7 @@ export enum TableName { Certificate = "certificates", CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", + CertificateTemplate = "certificate_templates", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index ff4689ebc..0d07d005b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -162,7 +162,11 @@ export enum EventType { UPDATE_PROJECT_KMS = "update-project-kms", GET_PROJECT_KMS_BACKUP = "get-project-kms-backup", LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup", - ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project", + CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", + UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", + DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", + GET_CERTIFICATE_TEMPLATE = "get-certificate-template" } interface UserActorMetadata { @@ -1366,6 +1370,46 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -1499,4 +1543,8 @@ export type Event = | UpdateProjectKmsEvent | GetProjectKmsBackupEvent | LoadProjectKmsBackupEvent - | OrgAdminAccessProjectEvent; + | OrgAdminAccessProjectEvent + | CreateCertificateTemplate + | UpdateCertificateTemplate + | GetCertificateTemplate + | DeleteCertificateTemplate; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 7ea2ba39f..f8f19a295 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -30,6 +30,7 @@ export enum ProjectPermissionSub { Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", + CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms" @@ -65,6 +66,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] @@ -165,6 +167,11 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); @@ -251,6 +258,8 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateTemplates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); can(ProjectPermissionActions.Read, ProjectPermissionSub.PkiCollections); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b3aa089ab..f4d645165 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1089,6 +1089,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", pkiCollectionId: "The ID of the PKI collection to add the certificate to", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", @@ -1147,6 +1148,32 @@ export const CERTIFICATES = { } }; +export const CERTIFICATE_TEMPLATES = { + CREATE: { + caId: "The ID of the certificate authority to associate the template with", + pkiCollectionId: "The ID of the PKI collection to bind to the template", + name: "The name of the template", + commonName: "The regular expression string to use for validating common names", + subjectAlternativeName: "The regular expression string to use for validating subject alternative names", + ttl: "The max TTL for the template" + }, + GET: { + certificateTemplateId: "The ID of the certificate template to get" + }, + UPDATE: { + certificateTemplateId: "The ID of the certificate template to update", + caId: "The ID of the certificate authority to update the association with the template", + pkiCollectionId: "The ID of the PKI collection to update the binding to the template", + name: "The updated name of the template", + commonName: "The updated regular expression string for validating common names", + subjectAlternativeName: "The updated regular expression string for validating subject alternative names", + ttl: "The updated max TTL for the template" + }, + DELETE: { + certificateTemplateId: "The ID of the certificate template to delete" + } +}; + export const ALERTS = { CREATE: { projectId: "The ID of the project to create the alert in", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 1aa1e18fc..d49c7aace 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -89,6 +89,8 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; +import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -587,6 +589,7 @@ export const registerRoutes = async ( const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); + const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -622,6 +625,7 @@ export const registerRoutes = async ( certificateAuthorityCertDAL, certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateAuthorityQueue, certificateDAL, certificateBodyDAL, @@ -641,6 +645,12 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateService = certificateTemplateServiceFactory({ + certificateTemplateDAL, + certificateAuthorityDAL, + permissionService + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -678,7 +688,8 @@ export const registerRoutes = async ( identityProjectMembershipRoleDAL, keyStore, kmsService, - projectBotDAL + projectBotDAL, + certificateTemplateDAL }); const projectEnvService = projectEnvServiceFactory({ @@ -1160,6 +1171,7 @@ export const registerRoutes = async ( auditLogStream: auditLogStreamService, certificate: certificateService, certificateAuthority: certificateAuthorityService, + certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 938fbf7fe..c492e0667 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -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,185 @@ 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), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + 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), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + 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", diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts new file mode 100644 index 000000000..3f3ecd5b3 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -0,0 +1,205 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_TEMPLATES } 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 { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.getCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), + name: z.string().min(1).describe(CERTIFICATE_TEMPLATES.CREATE.name), + commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), + subjectAlternativeName: validateTemplateRegexField.describe( + CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName + ), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_TEMPLATES.CREATE.ttl) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.createCertTemplate({ + 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: certificateTemplate.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), + name: z.string().min(1).optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), + commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), + subjectAlternativeName: validateTemplateRegexField + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.subjectAlternativeName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.ttl) + }), + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.updateCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.deleteCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 01bec65ad..e5f35565f 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -3,6 +3,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { registerCertRouter } from "./certificate-router"; +import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -76,6 +77,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); + await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); }, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a45cb51fa..61e175cfd 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -15,6 +15,7 @@ import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; +import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -458,4 +459,34 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { collections: pkiCollections }; } }); + + server.route({ + method: "GET", + url: "/:projectId/certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedCertificateTemplate.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectCertificateTemplates({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { certificateTemplates }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 88f686089..32dbc8fd0 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -5,6 +5,7 @@ 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, NotFoundError } from "@app/lib/errors"; @@ -18,6 +19,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 { @@ -59,6 +62,7 @@ type TCertificateAuthorityServiceFactoryDep = { >; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; + certificateTemplateDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick certificateDAL: Pick; certificateBodyDAL: Pick; @@ -76,6 +80,7 @@ export const certificateAuthorityServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateDAL, certificateBodyDAL, pkiCollectionDAL, @@ -1013,6 +1018,7 @@ export const certificateAuthorityServiceFactory = ({ */ const issueCertFromCa = async ({ caId, + certificateTemplateId, pkiCollectionId, friendlyName, commonName, @@ -1025,8 +1031,27 @@ 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; + let collectionId = pkiCollectionId; + + 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" + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1047,8 +1072,8 @@ export const certificateAuthorityServiceFactory = ({ } // check PKI collection - if (pkiCollectionId) { - const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); } @@ -1121,11 +1146,13 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1153,6 +1180,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -1179,8 +1218,9 @@ export const certificateAuthorityServiceFactory = ({ await certificateDAL.transaction(async (tx) => { const cert = await certificateDAL.create( { - caId: ca.id, + caId: (ca as TCertificateAuthorities).id, caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || commonName, commonName, @@ -1200,10 +1240,10 @@ export const certificateAuthorityServiceFactory = ({ tx ); - if (pkiCollectionId) { + if (collectionId) { await pkiCollectionItemDAL.create( { - pkiCollectionId, + pkiCollectionId: collectionId, certId: cert.id }, tx @@ -1237,6 +1277,7 @@ export const certificateAuthorityServiceFactory = ({ */ const signCertFromCa = async ({ caId, + certificateTemplateId, csr, pkiCollectionId, friendlyName, @@ -1250,8 +1291,27 @@ 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; + let collectionId = pkiCollectionId; + + 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" + }); + } + + collectionId = certificateTemplate.pkiCollectionId as string; + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1346,11 +1406,12 @@ export const certificateAuthorityServiceFactory = ({ await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) ]; + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; if (altNames) { - const altNamesArray: { - type: "email" | "dns"; - value: string; - }[] = altNames + altNamesArray = altNames .split(",") .map((name) => name.trim()) .map((altName) => { @@ -1378,6 +1439,18 @@ export const certificateAuthorityServiceFactory = ({ extensions.push(altNamesExtension); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate, + altNames: altNamesArray.map((entry) => entry.value) + }, + certificateTemplate + ); + } + const serialNumber = crypto.randomBytes(32).toString("hex"); const leafCert = await x509.X509CertificateGenerator.create({ serialNumber, @@ -1401,8 +1474,9 @@ export const certificateAuthorityServiceFactory = ({ await certificateDAL.transaction(async (tx) => { const cert = await certificateDAL.create( { - caId: ca.id, + caId: (ca as TCertificateAuthorities).id, caCertId: caCert.id, + certificateTemplateId: certificateTemplate?.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, commonName: cn, @@ -1422,10 +1496,10 @@ export const certificateAuthorityServiceFactory = ({ tx ); - if (pkiCollectionId) { + if (collectionId) { await pkiCollectionItemDAL.create( { - pkiCollectionId, + pkiCollectionId: collectionId, certId: cert.id }, tx diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 3fef204bf..c6bdeae30 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -86,7 +86,8 @@ export type TImportCertToCaDTO = { } & Omit; export type TIssueCertFromCaDTO = { - caId: string; + caId?: string; + certificateTemplateId?: string; pkiCollectionId?: string; friendlyName?: string; commonName: string; @@ -97,8 +98,9 @@ export type TIssueCertFromCaDTO = { } & Omit; export type TSignCertFromCaDTO = { - caId: string; + caId?: string; csr: string; + certificateTemplateId?: string; pkiCollectionId?: string; friendlyName?: string; commonName?: string; diff --git a/backend/src/services/certificate-template/certificate-template-dal.ts b/backend/src/services/certificate-template/certificate-template-dal.ts new file mode 100644 index 000000000..a30c4890d --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-dal.ts @@ -0,0 +1,57 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TCertificateTemplateDALFactory = ReturnType; + +export const certificateTemplateDALFactory = (db: TDbClient) => { + const certificateTemplateOrm = ormify(db, TableName.CertificateTemplate); + + const getCertTemplatesByProjectId = async (projectId: string) => { + try { + const certTemplates = await db + .replicaNode()(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + .where(`${TableName.CertificateAuthority}.projectId`, "=", projectId) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select( + db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority), + db.ref("projectId").withSchema(TableName.CertificateAuthority) + ); + + return certTemplates; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate templates by project ID" }); + } + }; + + const getById = async (id: string) => { + try { + const certTemplate = await db + .replicaNode()(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + .where(`${TableName.CertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select( + db.ref("projectId").withSchema(TableName.CertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get certificate template by ID" }); + } + }; + + return { ...certificateTemplateOrm, getCertTemplatesByProjectId, getById }; +}; diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts new file mode 100644 index 000000000..597be7eb2 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -0,0 +1,36 @@ +import ms from "ms"; + +import { TCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; + +export const validateCertificateDetailsAgainstTemplate = ( + cert: { + commonName: string; + notBeforeDate: Date; + notAfterDate: Date; + altNames: string[]; + }, + template: TCertificateTemplates +) => { + 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" + }); + } + + const subjectAlternativeNameRegex = new RegExp(template.subjectAlternativeName); + cert.altNames.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); +}; diff --git a/backend/src/services/certificate-template/certificate-template-schema.ts b/backend/src/services/certificate-template/certificate-template-schema.ts new file mode 100644 index 000000000..2ce787050 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-schema.ts @@ -0,0 +1,18 @@ +import z from "zod"; + +import { CertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ + id: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeName: true, + pkiCollectionId: true, + ttl: true +}).merge( + z.object({ + projectId: z.string(), + caName: z.string() + }) +); diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts new file mode 100644 index 000000000..c49791175 --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -0,0 +1,196 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { TCertificateTemplateDALFactory } from "./certificate-template-dal"; +import { + TCreateCertTemplateDTO, + TDeleteCertTemplateDTO, + TGetCertTemplateDTO, + TUpdateCertTemplateDTO +} from "./certificate-template-types"; + +type TCertificateTemplateServiceFactoryDep = { + certificateTemplateDAL: TCertificateTemplateDALFactory; + certificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TCertificateTemplateServiceFactory = ReturnType; + +export const certificateTemplateServiceFactory = ({ + certificateTemplateDAL, + certificateAuthorityDAL, + permissionService +}: TCertificateTemplateServiceFactoryDep) => { + const createCertTemplate = async ({ + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCertTemplateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ + message: "CA not found" + }); + } + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + const { id } = await certificateTemplateDAL.create({ + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + const certificateTemplate = await certificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + return certificateTemplate; + }; + + const updateCertTemplate = async ({ + id, + caId, + pkiCollectionId, + name, + commonName, + subjectAlternativeName, + ttl, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + if (caId) { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca || ca.projectId !== certTemplate.projectId) { + throw new BadRequestError({ + message: "Invalid CA" + }); + } + } + + await certificateTemplateDAL.updateById(certTemplate.id, { + caId, + pkiCollectionId, + commonName, + subjectAlternativeName, + name, + ttl + }); + + const updatedTemplate = await certificateTemplateDAL.getById(id); + if (!updatedTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + return updatedTemplate; + }; + + const deleteCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + await certificateTemplateDAL.deleteById(certTemplate.id); + + return certTemplate; + }; + + const getCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertTemplateDTO) => { + const certTemplate = await certificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: "Certificate template not found." + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + certTemplate.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return certTemplate; + }; + + return { + createCertTemplate, + getCertTemplate, + deleteCertTemplate, + updateCertTemplate + }; +}; diff --git a/backend/src/services/certificate-template/certificate-template-types.ts b/backend/src/services/certificate-template/certificate-template-types.ts new file mode 100644 index 000000000..c203b386c --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-types.ts @@ -0,0 +1,28 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateCertTemplateDTO = { + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; +} & Omit; + +export type TUpdateCertTemplateDTO = { + id: string; + caId?: string; + pkiCollectionId?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; +} & Omit; + +export type TGetCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts new file mode 100644 index 000000000..41a06b05d --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -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" + }); diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9416a66a9..a7111d784 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,7 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; @@ -41,6 +42,7 @@ import { TGetProjectKmsKey, TListProjectAlertsDTO, TListProjectCasDTO, + TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, @@ -73,6 +75,7 @@ type TProjectServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; + certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; permissionService: TPermissionServiceFactory; @@ -112,6 +115,7 @@ export const projectServiceFactory = ({ identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, + certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, keyStore, @@ -737,6 +741,36 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of certificate templates for project + */ + const listProjectCertificateTemplates = async ({ + projectId, + actorId, + actorOrgId, + actorAuthMethod, + actor + }: TListProjectCertificateTemplatesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId); + + return { + certificateTemplates + }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -857,6 +891,7 @@ export const projectServiceFactory = ({ listProjectCertificates, listProjectAlerts, listProjectPkiCollections, + listProjectCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ac53b3f71..ee2f1aea9 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -117,3 +117,5 @@ export type TLoadProjectKmsBackupDTO = { } & TProjectPermission; export type TGetProjectKmsKey = TProjectPermission; + +export type TListProjectCertificateTemplatesDTO = TProjectPermission; diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx new file mode 100644 index 000000000..56fcf3791 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-templates" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx new file mode 100644 index 000000000..c4f13d470 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx new file mode 100644 index 000000000..802dc5326 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx new file mode 100644 index 000000000..53c5f6fdf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" +--- diff --git a/docs/api-reference/endpoints/certificates/issue-certificate.mdx b/docs/api-reference/endpoints/certificates/issue-certificate.mdx new file mode 100644 index 000000000..90a79a4af --- /dev/null +++ b/docs/api-reference/endpoints/certificates/issue-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Certificate" +openapi: "POST /api/v1/pki/certificates/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/certificates/sign-certificate.mdx b/docs/api-reference/endpoints/certificates/sign-certificate.mdx new file mode 100644 index 000000000..3132d5846 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/sign-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign Certificate" +openapi: "POST /api/v1/pki/certificates/sign-certificate" +--- diff --git a/docs/documentation/platform/pki/certificate-templates.mdx b/docs/documentation/platform/pki/certificate-templates.mdx new file mode 100644 index 000000000..a52bf4364 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-templates.mdx @@ -0,0 +1,111 @@ +--- +title: "Certificate Templates" +sidebarTitle: "Certificate Templates" +description: "Learn how to use certificate templates to enforce policies." +--- + +## Concept + +In order to ensure your certificates follow certain policies, you can use certificate templates during the issuance and signing flows. + +A certificate template is linked to a certificate authority. It contains custom policies for certificate fields, allowing you to define rules based on your security policies. + +## Workflow + +The typical workflow for using certificate templates consists of the following steps: + +1. Creating a certificate template attached to an existing CA along with defining custom rules for certificate fields. +2. Selecting the certificate template during the creation of new certificates. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to using Certificate Templates + +In the following steps, we explore how to issue a X.509 certificate using a certificate template. + + + + + + + To create a certificate template, head to your Project > Internal PKI > Certificate Templates and press **Create Certificate Template**. + + ![certificate-template create template dashboard](/images/platform/pki/certificate-template/create-template-dashboard.png) + + Here, set the **Issuing CA** to the CA you want to issue certificates under when the certificate template is used. + + ![certificate-template create template modal](/images/platform/pki/certificate-template/create-template-form.png) + + Here's some guidance on each field: + - Template Name: A descriptive name for the certificate template. + - Issuing CA: The Certificate Authority (CA) that will issue certificates based on this template. + - Certificate Collection: The collection where certificates issued with this template will be added. + - Common Name (CN): The regular expression used to validate the common name in certificate requests. + - Alternative Names (SANs): The regular expression used to validate subject alternative names in certificate requests. + - TTL: The maximum Time-to-Live (TTL) for certificates issued using this template. + + + + Once you have created the certificate template from step 1, you can select it when issuing certificates. + + ![certificate-template select template](/images/platform/pki/certificate-template/select-template.png) + + + + + + + To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/certificate-templates \ + --header 'Content-Type: application/json' \ + --data '{ + "caId": "", + "pkiCollectionId": "", + "name": "", + "commonName": "", + "subjectAlternativeName": "", + "ttl": "" + }' + ``` + + ### Sample response + + ```bash Response + { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "caId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "certificate-template-1", + "commonName": "", + ... + } + ``` + + + To use the certificate template, attach the certificate template ID when invoking the API endpoint for [issuing](/api-reference/endpoints/certificates/issue-certificate) or [signing](/api-reference/endpoints/certificates/sign-certificate) new certificates. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/certificates/issue-certificate \ + --header 'Content-Type: application/json' \ + --data '{ + "certificateTemplateId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "friendlyName": "my-new-certificate", + "commonName": "CERT", + ... + }' + ``` + + + + + diff --git a/docs/images/platform/pki/certificate-template/create-template-dashboard.png b/docs/images/platform/pki/certificate-template/create-template-dashboard.png new file mode 100644 index 000000000..6f193effa Binary files /dev/null and b/docs/images/platform/pki/certificate-template/create-template-dashboard.png differ diff --git a/docs/images/platform/pki/certificate-template/create-template-form.png b/docs/images/platform/pki/certificate-template/create-template-form.png new file mode 100644 index 000000000..e69791edd Binary files /dev/null and b/docs/images/platform/pki/certificate-template/create-template-form.png differ diff --git a/docs/images/platform/pki/certificate-template/select-template.png b/docs/images/platform/pki/certificate-template/select-template.png new file mode 100644 index 000000000..c10031a45 Binary files /dev/null and b/docs/images/platform/pki/certificate-template/select-template.png differ diff --git a/docs/mint.json b/docs/mint.json index 123b33bb5..7b8fcdc42 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -108,6 +108,7 @@ "documentation/platform/pki/overview", "documentation/platform/pki/private-ca", "documentation/platform/pki/certificates", + "documentation/platform/pki/certificate-templates", "documentation/platform/pki/alerting" ] }, @@ -701,7 +702,18 @@ "api-reference/endpoints/certificates/read", "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", - "api-reference/endpoints/certificates/cert-body" + "api-reference/endpoints/certificates/cert-body", + "api-reference/endpoints/certificates/issue-certificate", + "api-reference/endpoints/certificates/sign-certificate" + ] + }, + { + "group": "Certificate Templates", + "pages": [ + "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" ] }, { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index c307688d6..6a4f84d02 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -28,6 +28,7 @@ export enum ProjectPermissionSub { Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", + CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms" @@ -59,6 +60,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 8aadda2d8..210061f7c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -68,7 +68,11 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_PKI_COLLECTION_ITEMS]: "Get PKI collection items", [EventType.ADD_PKI_COLLECTION_ITEM]: "Add PKI collection item", [EventType.DELETE_PKI_COLLECTION_ITEM]: "Delete PKI collection item", - [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project" + [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project", + [EventType.CREATE_CERTIFICATE_TEMPLATE]: "Create certificate template", + [EventType.UPDATE_CERTIFICATE_TEMPLATE]: "Update certificate template", + [EventType.DELETE_CERTIFICATE_TEMPLATE]: "Delete certificate template", + [EventType.GET_CERTIFICATE_TEMPLATE]: "Get certificate template" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index a6a2ada93..80c0ce431 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -82,5 +82,9 @@ export enum EventType { GET_PKI_COLLECTION_ITEMS = "get-pki-collection-items", ADD_PKI_COLLECTION_ITEM = "add-pki-collection-item", DELETE_PKI_COLLECTION_ITEM = "delete-pki-collection-item", - ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project", + CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", + UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", + DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", + GET_CERTIFICATE_TEMPLATE = "get-certificate-template" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 0a34186f6..d7825637d 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -679,6 +679,46 @@ interface OrgAdminAccessProjectEvent { }; // no metadata yet } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -747,7 +787,11 @@ export type Event = | GetPkiCollectionItems | AddPkiCollectionItem | DeletePkiCollectionItem - | OrgAdminAccessProjectEvent; + | OrgAdminAccessProjectEvent + | CreateCertificateTemplate + | UpdateCertificateTemplate + | GetCertificateTemplate + | DeleteCertificateTemplate; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 27d03248f..48652f3fd 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -99,9 +99,9 @@ export const useImportCaCertificate = () => { export const useCreateCertificate = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ caId, ...body }) => { + mutationFn: async (body) => { const { data } = await apiRequest.post( - `/api/v1/pki/ca/${caId}/issue-certificate`, + "/api/v1/pki/certificates/issue-certificate", body ); return data; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 692714871..e09ae16b8 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -79,7 +79,8 @@ export type TImportCaCertificateResponse = { export type TCreateCertificateDTO = { projectSlug: string; - caId: string; + caId?: string; + certificateTemplateId?: string; pkiCollectionId?: string; friendlyName?: string; commonName: string; diff --git a/frontend/src/hooks/api/certificateTemplates/index.tsx b/frontend/src/hooks/api/certificateTemplates/index.tsx new file mode 100644 index 000000000..b8145fbcc --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/index.tsx @@ -0,0 +1,2 @@ +export { useCreateCertTemplate, useDeleteCertTemplate, useUpdateCertTemplate } from "./mutations"; +export { useGetCertTemplate } from "./queries"; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx new file mode 100644 index 000000000..269d885a6 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { certTemplateKeys } from "./queries"; +import { + TCertificateTemplate, + TCreateCertificateTemplateDTO, + TDeleteCertificateTemplateDTO, + TUpdateCertificateTemplateDTO +} from "./types"; + +export const useCreateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/pki/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + } + }); +}; + +export const useUpdateCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/pki/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; + +export const useDeleteCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + return apiRequest.delete(`/api/v1/pki/certificate-templates/${data.id}`); + }, + onSuccess: (_, { projectId, id }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificateTemplates(projectId)); + queryClient.invalidateQueries(certTemplateKeys.getCertTemplateById(id)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx new file mode 100644 index 000000000..ca5b088e8 --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getCertTemplateById: (id: string) => [{ id }, "cert-template"] +}; + +export const useGetCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/pki/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts new file mode 100644 index 000000000..237a86a5e --- /dev/null +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -0,0 +1,37 @@ +export type TCertificateTemplate = { + id: string; + caId: string; + caName: string; + projectId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; +}; + +export type TCreateCertificateTemplateDTO = { + caId: string; + pkiCollectionId?: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; +}; + +export type TUpdateCertificateTemplateDTO = { + id: string; + caId?: string; + pkiCollectionId?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + projectId: string; +}; + +export type TDeleteCertificateTemplateDTO = { + id: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index d1ba46910..8057998aa 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -3,6 +3,7 @@ import { CertStatus } from "./enums"; export type TCertificate = { id: string; caId: string; + certificateTemplateId?: string; status: CertStatus; friendlyName: string; commonName: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 94037cb62..a9d24a5c7 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -7,6 +7,7 @@ export * from "./auth"; export * from "./bots"; export * from "./ca"; export * from "./certificates"; +export * from "./certificateTemplates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 3da83e1b8..3f320d0d9 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -25,6 +25,7 @@ export { useGetWorkspaceUsers, useListWorkspaceCas, useListWorkspaceCertificates, + useListWorkspaceCertificateTemplates, useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index d971f06e2..277499e9b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { CaStatus } from "../ca/enums"; import { TCertificateAuthority } from "../ca/types"; import { TCertificate } from "../certificates/types"; +import { TCertificateTemplate } from "../certificateTemplates/types"; import { TGroupMembership } from "../groups/types"; import { identitiesKeys } from "../identities/queries"; import { IdentityMembership } from "../identities/types"; @@ -68,7 +69,9 @@ export const workspaceKeys = { getWorkspacePkiAlerts: (workspaceId: string) => [{ workspaceId }, "workspace-pki-alerts"] as const, getWorkspacePkiCollections: (workspaceId: string) => - [{ workspaceId }, "workspace-pki-collections"] as const + [{ workspaceId }, "workspace-pki-collections"] as const, + getWorkspaceCertificateTemplates: (workspaceId: string) => + [{ workspaceId }, "workspace-certificate-templates"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -639,3 +642,19 @@ export const useListWorkspacePkiCollections = ({ workspaceId }: { workspaceId: s enabled: Boolean(workspaceId) }); }; + +export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspaceId: string }) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceCertificateTemplates(workspaceId), + queryFn: async () => { + const { + data: { certificateTemplates } + } = await apiRequest.get<{ certificateTemplates: TCertificateTemplate[] }>( + `/api/v2/workspace/${workspaceId}/certificate-templates` + ); + + return { certificateTemplates }; + }, + enabled: Boolean(workspaceId) + }); +}; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index f5e54ab47..ea0e687bb 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -406,6 +406,28 @@ export const LogsTableRow = ({ auditLog }: Props) => {

{`Cert CN: ${event.metadata.cn}`}

); + case EventType.CREATE_CERTIFICATE_TEMPLATE: + case EventType.UPDATE_CERTIFICATE_TEMPLATE: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+

{`Certificate Authority ID: ${event.metadata.caId}`}

+

{`Name: ${event.metadata.name}`}

+

{`Common Name: ${event.metadata.commonName}`}

+

{`Subject Alternative Name: ${event.metadata.subjectAlternativeName}`}

+

{`TTL: ${event.metadata.ttl}`}

+ {event.metadata.pkiCollectionId && ( +

{`Collection ID: ${event.metadata.pkiCollectionId}`}

+ )} + + ); + case EventType.GET_CERTIFICATE_TEMPLATE: + case EventType.DELETE_CERTIFICATE_TEMPLATE: + return ( + +

{`Certificate Template ID: ${event.metadata.certificateTemplateId}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx index 8dcd74348..71576d39d 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -1,6 +1,7 @@ import { motion } from "framer-motion"; import { PkiCollectionSection } from "../PkiAlertsTab/components"; +import { CertificateTemplatesSection } from "./components/CertificateTemplatesSection"; import { CertificatesSection } from "./components"; export const CertificatesTab = () => { @@ -13,6 +14,7 @@ export const CertificatesTab = () => { exit={{ opacity: 0, translateX: 30 }} > + ); diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx index 973934ce6..40645a07e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -1,5 +1,7 @@ import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -7,18 +9,22 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, + FormLabel, Input, Modal, ModalContent, Select, - SelectItem + SelectItem, + Tooltip } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { CaStatus, useCreateCertificate, useGetCert, + useGetCertTemplate, useListWorkspaceCas, + useListWorkspaceCertificateTemplates, useListWorkspacePkiCollections } from "@app/hooks/api"; import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; @@ -27,6 +33,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertificateContent } from "./CertificateContent"; const schema = z.object({ + certificateTemplateId: z.string().optional(), caId: z.string(), collectionId: z.string().optional(), friendlyName: z.string(), @@ -49,6 +56,8 @@ type TCertificateDetails = { privateKey: string; }; +const CERT_TEMPLATE_NONE_VALUE = "none"; + export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); const { currentWorkspace } = useWorkspace(); @@ -65,6 +74,10 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { workspaceId: currentWorkspace?.id || "" }); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id || "" + }); + const { mutateAsync: createCertificate } = useCreateCertificate(); const { @@ -72,11 +85,20 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { handleSubmit, reset, formState: { isSubmitting }, - setValue + setValue, + watch } = useForm({ 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({ @@ -84,6 +106,7 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { friendlyName: cert.friendlyName, commonName: cert.commonName, altNames: cert.altNames, + certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, ttl: "" }); } else { @@ -92,15 +115,22 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { friendlyName: "", commonName: "", altNames: "", - ttl: "" + ttl: "", + certificateTemplateId: CERT_TEMPLATE_NONE_VALUE }); } }, [cert]); + useEffect(() => { + if (!cert && selectedCertTemplate) { + setValue("ttl", selectedCertTemplate.ttl); + } + }, [selectedCertTemplate, cert]); + const onFormSubmit = async ({ caId, - collectionId, friendlyName, + collectionId, commonName, altNames, ttl @@ -109,8 +139,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, pkiCollectionId: collectionId, friendlyName, commonName, @@ -160,11 +191,31 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
( + + When a template is selected, the details provided are validated + against the template policies. + + } + > + + + } + /> + + } errorText={error?.message} isError={Boolean(error)} className="mt-4" @@ -177,42 +228,76 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(cert)} > - {(cas || []).map(({ id, type, dn }) => ( - - {`${caTypeToNameMap[type]}: ${dn}`} + + None + + {(templatesData?.certificateTemplates || []).map(({ id, name }) => ( + + {name} ))} )} /> - {!cert && ( - ( - - - - )} - /> + + + )} + /> + ( + + + + )} + /> + )} ; + +type Props = { + popUp: UsePopUpState<["certificateTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateTemplate"]>, + state?: boolean + ) => void; +}; + +export const CertificateTemplateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: certTemplate } = useGetCertTemplate( + (popUp?.certificateTemplate?.data as { id: string })?.id || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { data: collectionsData } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { mutateAsync: createCertTemplate } = useCreateCertTemplate(); + const { mutateAsync: updateCertTemplate } = useUpdateCertTemplate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (certTemplate) { + reset({ + caId: certTemplate.caId, + name: certTemplate.name, + commonName: certTemplate.commonName, + subjectAlternativeName: certTemplate.subjectAlternativeName, + collectionId: certTemplate.pkiCollectionId ?? undefined, + ttl: certTemplate.ttl + }); + } else { + reset({ + caId: "", + name: "", + commonName: "", + ttl: "" + }); + } + }, [certTemplate]); + + const onFormSubmit = async ({ + caId, + collectionId, + name, + commonName, + subjectAlternativeName, + ttl + }: FormData) => { + if (!currentWorkspace?.id) { + return; + } + + try { + if (certTemplate) { + await updateCertTemplate({ + id: certTemplate.id, + projectId: currentWorkspace.id, + pkiCollectionId: collectionId, + caId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + createNotification({ + text: "Successfully updated certificate template", + type: "success" + }); + } else { + await createCertTemplate({ + projectId: currentWorkspace.id, + pkiCollectionId: collectionId, + caId, + name, + commonName, + subjectAlternativeName, + ttl + }); + + createNotification({ + text: "Successfully created certificate template", + type: "success" + }); + } + + reset(); + handlePopUpToggle("certificateTemplate", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateTemplate", isOpen); + reset(); + }} + > + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + This field accepts limited regular expressions: spaces, *, ., @, -, \ + (for escaping), and alphanumeric characters only + + } + > + + + } + /> + + } + isError={Boolean(error)} + errorText={error?.message} + isRequired + > + + + )} + /> + ( + + + This field accepts limited regular expressions: spaces, *, ., @, -, \ + (for escaping), and alphanumeric characters only + + } + > + + + } + /> + + } + isError={Boolean(error)} + errorText={error?.message} + isRequired + > + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx new file mode 100644 index 000000000..3341e521c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx @@ -0,0 +1,87 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteCertTemplate } from "@app/hooks/api"; + +import { CertificateTemplateModal } from "./CertificateTemplateModal"; +import { CertificateTemplatesTable } from "./CertificateTemplatesTable"; + +export const CertificateTemplatesSection = () => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateTemplate", + "deleteCertificateTemplate" + ] as const); + + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCertTemplate } = useDeleteCertTemplate(); + + const onRemoveCertificateTemplateSubmit = async (id: string) => { + if (!currentWorkspace?.id) { + return; + } + + try { + await deleteCertTemplate({ + id, + projectId: currentWorkspace.id + }); + + await createNotification({ + text: "Successfully deleted certificate template", + type: "success" + }); + + handlePopUpClose("deleteCertificateTemplate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate template", + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Templates

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteCertificateTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateTemplateSubmit( + (popUp?.deleteCertificateTemplate?.data as { id: string })?.id + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx new file mode 100644 index 000000000..e504c9521 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesTable.tsx @@ -0,0 +1,117 @@ +import { faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificateTemplates } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["certificateTemplate", "deleteCertificateTemplate"]>, + data?: { + id?: string; + name?: string; + } + ) => void; +}; + +export const CertificateTemplatesTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id ?? "" + }); + + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificateTemplates.map((certificateTemplate) => { + return ( + + + + + + ); + })} + +
NameCertificate Authority +
{certificateTemplate.name}{certificateTemplate.caName} + + +
+ + + +
+
+ + + handlePopUpOpen("certificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Manage Policies + + + {(isAllowed) => ( + } + onClick={() => + handlePopUpOpen("deleteCertificateTemplate", { + id: certificateTemplate.id, + name: certificateTemplate.name + }) + } + > + Delete Template + + )} + + +
+
+ {!isLoading && !data?.certificateTemplates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx index 58bb8ef4c..962083b22 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -61,7 +61,7 @@ export const CertificatesSection = () => { onClick={() => handlePopUpOpen("certificate")} isDisabled={!isAllowed} > - Issue Certificate + Issue )} diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx index bd2086ade..a3a6106f3 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -209,7 +209,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { /> )} {!isLoading && !data?.certificates?.length && ( - + )} ); diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index abab188b8..e3b05a41f 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -53,6 +53,7 @@ export const formSchema = z.object({ certificates: generalPermissionSchema, "pki-alerts": generalPermissionSchema, "pki-collections": generalPermissionSchema, + "certificate-templates": generalPermissionSchema, // akhilmhdh: refactor all keys like below [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, workspace: z diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 40e4c260a..1de24ae71 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -81,6 +81,10 @@ const SINGLE_PERMISSION_LIST = [ title: "Certificates", formName: "certificates" }, + { + title: "Certificate Templates", + formName: "certificate-templates" + }, { title: "PKI Collections", formName: "pki-collections"