From 86862b932cda379e6bd89725b94b7b2570247973 Mon Sep 17 00:00:00 2001 From: = Date: Fri, 30 May 2025 14:40:25 +0530 Subject: [PATCH] feat: completed backend changes for new pki template --- backend/src/@types/fastify.d.ts | 2 + .../20250528145356_add-template-slug.ts | 24 + .../ee/services/oidc/oidc-config-service.ts | 1 + .../ee/services/permission/default-roles.ts | 27 +- .../services/permission/project-permission.ts | 54 +- backend/src/server/routes/index.ts | 19 + backend/src/server/routes/v2/index.ts | 11 +- .../server/routes/v2/pki-templates-router.ts | 309 +++++++++ .../internal-certificate-authority-fns.ts | 274 +++++++- .../internal-certificate-authority-service.ts | 15 +- .../internal-certificate-authority-types.ts | 10 + .../certificate-template-schema.ts | 17 + .../certificate-template-service.ts | 42 +- .../pki-templates/pki-templates-dal.ts | 102 +++ .../pki-templates/pki-templates-service.ts | 624 ++++++++++++++++++ .../pki-templates/pki-templates-types.ts | 53 ++ .../src/services/project/project-service.ts | 13 +- 17 files changed, 1552 insertions(+), 45 deletions(-) create mode 100644 backend/src/db/migrations/20250528145356_add-template-slug.ts create mode 100644 backend/src/server/routes/v2/pki-templates-router.ts create mode 100644 backend/src/services/pki-templates/pki-templates-dal.ts create mode 100644 backend/src/services/pki-templates/pki-templates-service.ts create mode 100644 backend/src/services/pki-templates/pki-templates-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a32ed56a31..3fcb06fbf7 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -83,6 +83,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -271,6 +272,7 @@ declare module "fastify" { assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; + pkiTemplate: TPkiTemplatesServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/db/migrations/20250528145356_add-template-slug.ts b/backend/src/db/migrations/20250528145356_add-template-slug.ts new file mode 100644 index 0000000000..34a7e38f83 --- /dev/null +++ b/backend/src/db/migrations/20250528145356_add-template-slug.ts @@ -0,0 +1,24 @@ +import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasNameCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "name"); + if (hasNameCol) { + const templates = await knex(TableName.CertificateTemplate).select("id", "name"); + await Promise.all( + templates.map((el) => { + const slugifiedName = el.name + ? slugify(`${el.name.slice(0, 16)}-${alphaNumericNanoId(8)}`) + : slugify(alphaNumericNanoId(12)); + + return knex(TableName.CertificateTemplate).where({ id: el.id }).update({ name: slugifiedName }); + }) + ); + } +} + +export async function down(): Promise {} diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index d933835e48..7cebc18252 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -15,6 +15,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index a3f444a119..f0993b30c0 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -10,6 +10,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretSyncActions, @@ -35,7 +36,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, @@ -56,10 +56,24 @@ const buildAdminPermissionRules = () => { can( [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionPkiTemplateActions.IssueCert, + ProjectPermissionPkiTemplateActions.ListCerts + ], + ProjectPermissionSub.CertificateTemplates + ); + + can( + [ + ProjectPermissionApprovalActions.Read, + ProjectPermissionApprovalActions.Edit, + ProjectPermissionApprovalActions.Create, + ProjectPermissionApprovalActions.Delete, + ProjectPermissionApprovalActions.AllowChangeBypass, + ProjectPermissionApprovalActions.AllowAccessBypass ], ProjectPermissionSub.SecretApproval ); @@ -348,7 +362,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); @@ -417,6 +431,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 14408e8a06..48fae49cdb 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -87,6 +87,15 @@ export enum ProjectPermissionSshHostActions { IssueHostCert = "issue-host-cert" } +export enum ProjectPermissionPkiTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert", + ListCerts = "list-certs" +} + export enum ProjectPermissionPkiSubscriberActions { Read = "read", Create = "create", @@ -200,6 +209,11 @@ export type SshHostSubjectFields = { hostname: string; }; +export type PkiTemplateSubjectFields = { + name: string; + // (dangtony98): consider adding [commonName] as a subject field in the future +}; + export type PkiSubscriberSubjectFields = { name: string; // (dangtony98): consider adding [commonName] as a subject field in the future @@ -256,7 +270,13 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] - | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ + ProjectPermissionPkiTemplateActions, + ( + | ProjectPermissionSub.CertificateTemplates + | (ForcedSubject & PkiTemplateSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] @@ -436,6 +456,21 @@ const PkiSubscriberConditionSchema = z }) .partial(); +const PkiTemplateConditionSchema = z + .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -527,12 +562,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z .literal(ProjectPermissionSub.SshCertificateAuthorities) @@ -710,6 +739,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiTemplateActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: PkiTemplateConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), @@ -720,6 +759,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + ...GeneralPermissionSchema ]); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2b788bb2be..5971eb4570 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -211,6 +211,8 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue"; import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; +import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -847,6 +849,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -1754,6 +1757,21 @@ export const registerRoutes = async ( internalCaFns }); + const pkiTemplateService = pkiTemplatesServiceFactory({ + pkiTemplatesDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + permissionService, + internalCaFns + }); + await secretRotationV2QueueServiceFactory({ secretRotationV2Service, secretRotationV2DAL, @@ -1847,6 +1865,7 @@ export const registerRoutes = async ( pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, + pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index fb055877d5..93c422d153 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -5,6 +5,7 @@ import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerServiceTokenRouter } from "./service-token-router"; @@ -15,7 +16,15 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); - await server.register(registerCaRouter, { prefix: "/pki/ca" }); + + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/pki" } + ); + await server.register( async (orgRouter) => { await orgRouter.register(registerOrgRouter); diff --git a/backend/src/server/routes/v2/pki-templates-router.ts b/backend/src/server/routes/v2/pki-templates-router.ts new file mode 100644 index 0000000000..a085aecdb2 --- /dev/null +++ b/backend/src/server/routes/v2/pki-templates-router.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; + +import { CertificateTemplatesSchema } from "@app/db/schemas"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +export const registerPkiTemplatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: z.object({ + name: slugSchema(), + caId: z.string(), + projectId: z.string(), + commonName: validateTemplateRegexField, + subjectAlternativeName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + name: slugSchema().optional(), + caId: z.string(), + projectId: z.string(), + commonName: validateTemplateRegexField.optional(), + subjectAlternativeName: validateTemplateRegexField.optional(), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional(), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.updateTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: z.string().min(1) + }), + body: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.deleteTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.body.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/:templateName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.getTemplateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.query.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string(), + limit: z.coerce.number().default(100), + offset: z.coerce.number().default(0) + }), + response: { + 200: z.object({ + certificateTemplates: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates, totalCount } = await server.services.pkiTemplate.listTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { certificateTemplates, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + commonName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: validateAltNamesField + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.issueCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + csr: z.string().trim().min(1) + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.signCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); +}; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 457863121b..643a9e1274 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -1,8 +1,10 @@ +/* eslint-disable no-bitwise */ import * as x509 from "@peculiar/x509"; import { KeyObject } from "crypto"; +import RE2 from "re2"; import { z } from "zod"; -import { TPkiSubscribers } from "@app/db/schemas"; +import { TCertificateTemplates, TPkiSubscribers } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -31,6 +33,7 @@ import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { certificateAuthorityDAL: Pick; @@ -257,7 +260,274 @@ export const InternalCertificateAuthorityFns = ({ }; }; + const issueCertificateWithTemplate = async ( + ca: Awaited>, + certificateTemplate: TCertificateTemplates, + { altNames, commonName, ttl, extendedKeyUsages, keyUsages, notAfter, notBefore }: TIssueCertWithTemplateDTO + ) => { + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const commonNameRegex = new RE2(certificateTemplate.commonName); + if (!commonNameRegex.test(commonName)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (notAfterDate.getTime() - notBeforeDate.getTime() > ms(certificateTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const subjectAlternativeNameRegex = new RE2(certificateTemplate.subjectAlternativeName); + altNames.split(",").forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + + if (keyUsages === undefined && certificateTemplate) { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && certificateTemplate) { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + + if (altNames) { + altNamesArray = altNames.split(",").map((altName) => { + if (z.string().email().safeParse(altName).success) { + return { type: "email", value: altName }; + } + + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId, + certificateTemplateId: certificateTemplate.id + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca, + template: certificateTemplate + }; + }; + return { - issueCertificate + issueCertificate, + issueCertificateWithTemplate }; }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 8c16ef3c20..0831172415 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1,5 +1,5 @@ /* eslint-disable no-bitwise */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import slugify from "@sindresorhus/slugify"; import crypto, { KeyObject } from "crypto"; @@ -16,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; @@ -1952,15 +1953,15 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const certificateTemplates = await certificateTemplateDAL.find({ caId }); return { - certificateTemplates, + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), ca: expandInternalCa(ca) }; }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index f8ea82a59f..fadd7b88d0 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -221,3 +221,13 @@ export type TOrderCertificateForSubscriberDTO = { subscriberId: string; caType: CaType; }; + +export type TIssueCertWithTemplateDTO = { + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +}; diff --git a/backend/src/services/certificate-template/certificate-template-schema.ts b/backend/src/services/certificate-template/certificate-template-schema.ts index 7a87daddf0..6ab39af7d8 100644 --- a/backend/src/services/certificate-template/certificate-template-schema.ts +++ b/backend/src/services/certificate-template/certificate-template-schema.ts @@ -18,3 +18,20 @@ export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ caName: z.string() }) ); + +export const sanitizedCertificateTemplateV2 = CertificateTemplatesSchema.pick({ + id: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeName: true, + pkiCollectionId: true, + ttl: true, + keyUsages: true, + extendedKeyUsages: 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 index 04bf76f5c3..be1200503d 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -1,11 +1,14 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import bcrypt from "bcrypt"; import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -78,8 +81,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) ); return certificateTemplateDAL.transaction(async (tx) => { @@ -140,8 +143,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); if (caId) { @@ -153,6 +156,13 @@ export const certificateTemplateServiceFactory = ({ } } + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + } + return certificateTemplateDAL.transaction(async (tx) => { await certificateTemplateDAL.updateById( certTemplate.id, @@ -198,8 +208,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); await certificateTemplateDAL.deleteById(certTemplate.id); @@ -225,8 +235,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); return certTemplate; @@ -267,8 +277,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const appCfg = getConfig(); @@ -350,8 +360,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const originalCaEstConfig = await certificateTemplateEstConfigDAL.findOne({ @@ -430,8 +440,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/pki-templates/pki-templates-dal.ts b/backend/src/services/pki-templates/pki-templates-dal.ts new file mode 100644 index 0000000000..45c632d705 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-dal.ts @@ -0,0 +1,102 @@ +import { Knex } from "knex"; +import { Tables } from "knex/types/tables"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, TFindReturn } from "@app/lib/knex"; + +export type TPkiTemplatesDALFactory = ReturnType; + +export const pkiTemplatesDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.CertificateTemplate); + + const findOne = async ( + filter: Partial, + tx?: Knex + ) => { + try { + const { projectId, ...templateFilters } = filter; + const res = await (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .first(); + + if (!res) return undefined; + + return { ...res, ca: { id: res.caId, name: res.caName } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + const find = async < + TCount extends boolean = false, + TCountDistinct extends keyof Tables[TableName.CertificateTemplate]["base"] | undefined = undefined + >( + filter: TFindFilter & { projectId: string }, + { + offset, + limit, + sort, + count, + tx, + countDistinct + }: TFindOpt = {} + ) => { + try { + const { projectId, ...templateFilters } = filter; + + const query = (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")); + + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + } + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = (await query) as TFindReturn; + return res.map((el) => ({ ...el, ca: { id: el.caId, name: el.caName } })); + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, find, findOne }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-service.ts b/backend/src/services/pki-templates/pki-templates-service.ts new file mode 100644 index 0000000000..96ffa01831 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-service.ts @@ -0,0 +1,624 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; + +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaStatus } from "../certificate-authority/certificate-authority-enums"; +import { + createSerialNumber, + expandInternalCa, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "../certificate-authority/certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority/certificate-authority-secret-dal"; +import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TPkiTemplatesDALFactory } from "./pki-templates-dal"; +import { + TCreatePkiTemplateDTO, + TDeletePkiTemplateDTO, + TGetPkiTemplateDTO, + TIssueCertPkiTemplateDTO, + TListPkiTemplateDTO, + TSignCertPkiTemplateDTO, + TUpdatePkiTemplateDTO +} from "./pki-templates-types"; + +type TPkiTemplatesServiceFactoryDep = { + pkiTemplatesDAL: TPkiTemplatesDALFactory; + permissionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa" + >; + internalCaFns: ReturnType; + kmsService: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find" + >; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; +}; + +export type TPkiTemplatesServiceFactory = ReturnType; + +export const pkiTemplatesServiceFactory = ({ + pkiTemplatesDAL, + permissionService, + internalCaFns, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + kmsService, + projectDAL +}: TPkiTemplatesServiceFactoryDep) => { + const createTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + caId, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl + }: TCreatePkiTemplateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ + message: `CA with ID ${caId} not found` + }); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + + const newTemplate = await pkiTemplatesDAL.create({ + caId, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return newTemplate; + }; + + const updateTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + caId, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl, + projectId + }: TUpdatePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + if (caId) { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca || ca.projectId !== certTemplate.projectId) { + throw new NotFoundError({ + message: `CA with ID ${caId} not found` + }); + } + } + + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + } + + const updatedTemplate = await pkiTemplatesDAL.updateById(certTemplate.id, { + caId, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return updatedTemplate; + }; + + const deleteTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TDeletePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const deletedTemplate = await pkiTemplatesDAL.deleteById(certTemplate.id); + return deletedTemplate; + }; + + const getTemplateByName = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TGetPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + return certTemplate; + }; + + const listTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + limit, + offset + }: TListPkiTemplateDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const certTemplate = await pkiTemplatesDAL.find({ projectId }, { limit, offset, count: true }); + return { + certificateTemplates: certTemplate.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), + totalCount: Number(certTemplate?.[0]?.count ?? 0) + }; + }; + + const issueCertificate = async ({ + templateName, + projectId, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId, + keyUsages, + extendedKeyUsages + }: TIssueCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (ca.internalCa?.id) { + return internalCaFns.issueCertificateWithTemplate(ca, certTemplate, { + altNames, + commonName, + ttl, + extendedKeyUsages, + keyUsages, + notAfter, + notBefore + }); + } + + throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); + }; + + const signCertificate = async ({ + templateName, + csr, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + ttl + }: TSignCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const appCfg = getConfig(); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${certTemplate.caId}' not found` }); + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(ttl ?? "0")); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const dn = parseDistinguishedName(csrObj.subject); + const cn = dn.commonName; + if (!cn) + throw new BadRequestError({ + message: "Missing common name on CSR" + }); + + const commonNameRegex = new RE2(certTemplate.commonName); + if (!commonNameRegex.test(cn)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (ms(ttl) > ms(certTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension | undefined; // Better to type as optional + let selectedKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension && csrKeyUsageExtension.usages) { + selectedKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + const validKeyUsages = certTemplate.keyUsages || []; + if (selectedKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + } + + // handle extended key usage + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension | undefined; + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension && csrExtendedKeyUsageExtension.usages.length > 0) { + selectedExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + + if (selectedExtendedKeyUsages.some((eku) => !certTemplate?.extendedKeyUsages?.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on subscriber's specified extended key usages" + }); + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + } + + // attempt to read from CSR if altNames is not explicitly provided + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const subjectAlternativeNameRegex = new RE2(certTemplate.subjectAlternativeName); + altNamesArray.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName.value)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: cn, + commonName: cn, + altNames: altNamesArray.map((el) => el.value).join(","), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca: expandInternalCa(ca), + commonName: cn, + template: certTemplate + }; + }; + + return { + createTemplate, + updateTemplate, + getTemplateByName, + listTemplate, + deleteTemplate, + signCertificate, + issueCertificate + }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-types.ts b/backend/src/services/pki-templates/pki-templates-types.ts new file mode 100644 index 0000000000..72d245de12 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-types.ts @@ -0,0 +1,53 @@ +import { TProjectPermission } from "@app/lib/types"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; + +export type TCreatePkiTemplateDTO = { + caId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TUpdatePkiTemplateDTO = { + templateName: string; + caId?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TListPkiTemplateDTO = { + limit?: number; + offset?: number; +} & TProjectPermission; + +export type TGetPkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TDeletePkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TIssueCertPkiTemplateDTO = { + templateName: string; + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TSignCertPkiTemplateDTO = { + templateName: string; + csr: string; + ttl: string; +} & TProjectPermission; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index d042ee32a0..46f6097777 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -17,6 +17,7 @@ import { ProjectPermissionActions, ProjectPermissionCertificateActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -1131,15 +1132,15 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId); return { - certificateTemplates + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ) }; };