diff --git a/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts b/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts new file mode 100644 index 000000000..cb5e44a03 --- /dev/null +++ b/backend/src/db/migrations/20250429232917_store-cert-secret-key-and-chain.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.binary("encryptedCertificateChain").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateSecret))) { + await knex.schema.createTable(TableName.CertificateSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateSecret)) { + await knex.schema.dropTable(TableName.CertificateSecret); + } + + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.dropColumn("encryptedCertificateChain"); + }); + } +} diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts index 75afbddbd..10171e383 100644 --- a/backend/src/db/schemas/certificate-bodies.ts +++ b/backend/src/db/schemas/certificate-bodies.ts @@ -14,7 +14,8 @@ export const CertificateBodiesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - encryptedCertificate: zodBuffer + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer.nullable().optional() }); export type TCertificateBodies = z.infer; 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 7186a56e1..5d27b37f3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -225,6 +225,8 @@ export enum EventType { DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", GET_CERT_BODY = "get-cert-body", + GET_CERT_PRIVATE_KEY = "get-cert-private-key", + GET_CERT_BUNDLE = "get-cert-bundle", CREATE_PKI_ALERT = "create-pki-alert", GET_PKI_ALERT = "get-pki-alert", UPDATE_PKI_ALERT = "update-pki-alert", @@ -1800,6 +1802,24 @@ interface GetCertBody { }; } +interface GetCertPrivateKey { + type: EventType.GET_CERT_PRIVATE_KEY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBundle { + type: EventType.GET_CERT_BUNDLE; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + interface CreatePkiAlert { type: EventType.CREATE_PKI_ALERT; metadata: { @@ -2835,6 +2855,8 @@ export type Event = | DeleteCert | RevokeCert | GetCertBody + | GetCertPrivateKey + | GetCertBundle | CreatePkiAlert | GetPkiAlert | UpdatePkiAlert diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 05d492240..f653d0c0c 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -24,8 +24,16 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = if (net.isIPv4(el)) { exclusiveIps.push(el); } else { - const resolvedIps = await dns.resolve4(el); - exclusiveIps.push(...resolvedIps); + try { + const resolvedIps = await dns.resolve4(el); + exclusiveIps.push(...resolvedIps); + } catch (error) { + // only try lookup if not found + if ((error as { code: string })?.code !== "ENOTFOUND") throw error; + + const resolvedIps = (await dns.lookup(el, { all: true, family: 4 })).map(({ address }) => address); + exclusiveIps.push(...resolvedIps); + } } } } @@ -38,8 +46,16 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = if (normalizedHost === "localhost" || normalizedHost === "host.docker.internal") { throw new BadRequestError({ message: "Invalid db host" }); } - const resolvedIps = await dns.resolve4(host); - inputHostIps.push(...resolvedIps); + try { + const resolvedIps = await dns.resolve4(host); + inputHostIps.push(...resolvedIps); + } catch (error) { + // only try lookup if not found + if ((error as { code: string })?.code !== "ENOTFOUND") throw error; + + const resolvedIps = (await dns.lookup(host, { all: true, family: 4 })).map(({ address }) => address); + inputHostIps.push(...resolvedIps); + } } if (!isGateway && !(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 319a0259a..993653045 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -17,6 +17,14 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionCertificateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + ReadPrivateKey = "read-private-key" +} + export enum ProjectPermissionSecretActions { DescribeAndReadValue = "read", DescribeSecret = "describeSecret", @@ -232,7 +240,7 @@ export type ProjectPermissionSet = ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] - | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] @@ -478,7 +486,7 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateActions).describe( "Describe what action an entity can take." ) }), @@ -688,7 +696,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.Certificates, ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, @@ -708,6 +715,17 @@ const buildAdminPermissionRules = () => { ); }); + can( + [ + ProjectPermissionCertificateActions.Read, + ProjectPermissionCertificateActions.Edit, + ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Delete, + ProjectPermissionCertificateActions.ReadPrivateKey + ], + ProjectPermissionSub.Certificates + ); + can( [ ProjectPermissionSshHostActions.Edit, @@ -965,10 +983,10 @@ const buildMemberPermissionRules = () => { can( [ - ProjectPermissionActions.Read, - ProjectPermissionActions.Edit, - ProjectPermissionActions.Create, - ProjectPermissionActions.Delete + ProjectPermissionCertificateActions.Read, + ProjectPermissionCertificateActions.Edit, + ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Delete ], ProjectPermissionSub.Certificates ); @@ -1041,7 +1059,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); - can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fb5cca9e1..6ecb748c8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1619,7 +1619,8 @@ export const CERTIFICATES = { serialNumber: "The serial number of the certificate to get the certificate body and certificate chain for.", certificate: "The certificate body of the certificate.", certificateChain: "The certificate chain of the certificate.", - serialNumberRes: "The serial number of the certificate." + serialNumberRes: "The serial number of the certificate.", + privateKey: "The private key of the certificate." }, IMPORT: { projectSlug: "Slug of the project to import the certificate into.", diff --git a/backend/src/server/lib/caching.ts b/backend/src/server/lib/caching.ts new file mode 100644 index 000000000..513f2f635 --- /dev/null +++ b/backend/src/server/lib/caching.ts @@ -0,0 +1,8 @@ +import { FastifyReply } from "fastify"; + +export const addNoCacheHeaders = (reply: FastifyReply) => { + void reply.header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + void reply.header("Pragma", "no-cache"); + void reply.header("Expires", "0"); + void reply.header("Surrogate-Control", "no-store"); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5542818df..a0b6bb5bf 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -126,6 +126,7 @@ import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; @@ -812,6 +813,7 @@ export const registerRoutes = async ( const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); + const certificateSecretDAL = certificateSecretDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); @@ -820,6 +822,7 @@ export const registerRoutes = async ( const certificateService = certificateServiceFactory({ certificateDAL, certificateBodyDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthorityCrlDAL, @@ -893,6 +896,7 @@ export const registerRoutes = async ( certificateAuthorityQueue, certificateDAL, certificateBodyDAL, + certificateSecretDAL, pkiCollectionDAL, pkiCollectionItemDAL, projectDAL, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 271e0f776..9a527d99d 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -5,6 +6,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { addNoCacheHeaders } from "@app/server/lib/caching"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -64,6 +66,111 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. + server.route({ + method: "GET", + url: "/:serialNumber/private-key", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate private key", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.string().trim() + } + }, + handler: async (req, reply) => { + const { cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.GET_CERT_PRIVATE_KEY, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return certPrivateKey; + } + }); + + // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. + server.route({ + method: "GET", + url: "/:serialNumber/bundle", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate bundle including the certificate, chain, and private key.", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req, reply) => { + const { certificate, certificateChain, serialNumber, cert, privateKey } = + await server.services.certificate.getCertBundle({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.GET_CERT_BUNDLE, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return { + certificate, + certificateChain, + serialNumber, + privateKey + }; + } + }); + server.route({ method: "POST", url: "/issue-certificate", diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 67903b265..aa1ed07d8 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -6,7 +6,11 @@ import { z } from "zod"; import { ActionProjectType, ProjectType, 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 { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + 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"; @@ -21,6 +25,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { CertExtendedKeyUsage, CertExtendedKeyUsageOIDToName, @@ -75,6 +80,7 @@ type TCertificateAuthorityServiceFactoryDep = { certificateTemplateDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick certificateDAL: Pick; + certificateSecretDAL: Pick; certificateBodyDAL: Pick; pkiCollectionDAL: Pick; pkiCollectionItemDAL: Pick; @@ -96,6 +102,7 @@ export const certificateAuthorityServiceFactory = ({ certificateTemplateDAL, certificateDAL, certificateBodyDAL, + certificateSecretDAL, pkiCollectionDAL, pkiCollectionItemDAL, projectDAL, @@ -1157,7 +1164,10 @@ export const certificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); @@ -1373,6 +1383,23 @@ export const certificateAuthorityServiceFactory = ({ 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( @@ -1397,7 +1424,16 @@ export const certificateAuthorityServiceFactory = ({ await certificateBodyDAL.create( { certId: cert.id, - encryptedCertificate + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey }, tx ); @@ -1415,17 +1451,9 @@ export const certificateAuthorityServiceFactory = ({ return cert; }); - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: caCert.id, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - return { certificate: leafCert.toString("pem"), - certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + certificateChain: certificateChainPem, issuingCaCertificate, privateKey: skLeaf, serialNumber, @@ -1488,7 +1516,7 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionCertificateActions.Create, ProjectPermissionSub.Certificates ); } @@ -1766,6 +1794,20 @@ export const certificateAuthorityServiceFactory = ({ plainText: Buffer.from(new Uint8Array(leafCert.rawData)) }); + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.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( { @@ -1789,7 +1831,8 @@ export const certificateAuthorityServiceFactory = ({ await certificateBodyDAL.create( { certId: cert.id, - encryptedCertificate + encryptedCertificate, + encryptedCertificateChain }, tx ); @@ -1807,17 +1850,9 @@ export const certificateAuthorityServiceFactory = ({ return cert; }); - const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caCertId: ca.activeCaCertId, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - projectDAL, - kmsService - }); - return { certificate: leafCert, - certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + certificateChain: certificateChainPem, issuingCaCertificate, serialNumber, ca, diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index ff4e4394a..9fe77de21 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -1,6 +1,11 @@ +import crypto from "node:crypto"; + import * as x509 from "@peculiar/x509"; -import { CrlReason } from "./certificate-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { CrlReason, TGetCertificateCredentialsDTO } from "./certificate-types"; export const revocationReasonToCrlCode = (crlReason: CrlReason) => { switch (crlReason) { @@ -49,3 +54,47 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) export const splitPemChain = (pemText: string) => pemText.match(/-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----/g) || []; + +/** + * Return the public and private key of certificate + * Note: credentials are returned as PEM strings + */ +export const getCertificateCredentials = async ({ + certId, + projectId, + certificateSecretDAL, + projectDAL, + kmsService +}: TGetCertificateCredentialsDTO) => { + const certificateSecret = await certificateSecretDAL.findOne({ certId }); + if (!certificateSecret) + throw new NotFoundError({ message: `Certificate secret for certificate with ID '${certId}' not found` }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: keyId + }); + const decryptedPrivateKey = await kmsDecryptor({ + cipherTextBlob: certificateSecret.encryptedPrivateKey + }); + + try { + const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "pem", type: "pkcs8" }); + const certPrivateKey = skObj.export({ format: "pem", type: "pkcs8" }).toString(); + + const pkObj = crypto.createPublicKey(skObj); + const certPublicKey = pkObj.export({ format: "pem", type: "spki" }).toString(); + + return { + certificateSecret, + certPrivateKey, + certPublicKey + }; + } catch (error) { + throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` }); + } +}; diff --git a/backend/src/services/certificate/certificate-secret-dal.ts b/backend/src/services/certificate/certificate-secret-dal.ts new file mode 100644 index 000000000..c1493eceb --- /dev/null +++ b/backend/src/services/certificate/certificate-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateSecretDALFactory = ReturnType; + +export const certificateSecretDALFactory = (db: TDbClient) => { + const certSecretOrm = ormify(db, TableName.CertificateSecret); + return certSecretOrm; +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index aa2456349..634fe791b 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -5,7 +5,10 @@ import { createPrivateKey, createPublicKey, sign, verify } from "crypto"; import { ActionProjectType, ProjectType } 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -19,7 +22,8 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; -import { revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; +import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; +import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { CertExtendedKeyUsage, CertExtendedKeyUsageOIDToName, @@ -27,13 +31,16 @@ import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, + TGetCertBundleDTO, TGetCertDTO, + TGetCertPrivateKeyDTO, TImportCertDTO, TRevokeCertDTO } from "./certificate-types"; type TCertificateServiceFactoryDep = { certificateDAL: Pick; + certificateSecretDAL: Pick; certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; @@ -53,6 +60,7 @@ export type TCertificateServiceFactory = ReturnType { + const cert = await certificateDAL.findOne({ serialNumber }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: cert.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + cert, + certPrivateKey + }; + }; + /** * Delete certificate with serial number [serialNumber] */ @@ -101,7 +152,10 @@ export const certificateServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Delete, + ProjectPermissionSub.Certificates + ); const deletedCert = await certificateDAL.deleteById(cert.id); @@ -140,7 +194,10 @@ export const certificateServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Delete, + ProjectPermissionSub.Certificates + ); if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); @@ -186,7 +243,10 @@ export const certificateServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); @@ -207,8 +267,14 @@ export const certificateServiceFactory = ({ let certificateChain = null; - // TODO(andrey): Update this to get certificateChain straight from the certificate body after the "store cert chain on cert body" PR gets merged - if (cert.caCertId) { + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { const { caCert, caCertChain } = await getCaCertChain({ caCertId: cert.caCertId, certificateAuthorityDAL, @@ -266,7 +332,10 @@ export const certificateServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); // Check PKI collection if (collectionId) { @@ -361,6 +430,10 @@ export const certificateServiceFactory = ({ plainText: Buffer.from(certificatePem) }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(privateKeyPem) + }); + // Extract Key Usage const keyUsagesExt = leafCert.getExtension("2.5.29.15") as x509.KeyUsagesExtension; @@ -379,6 +452,10 @@ export const certificateServiceFactory = ({ extendedKeyUsages = extKeyUsageExt.usages.map((ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]); } + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(chainPem) + }); + const cert = await certificateDAL.transaction(async (tx) => { try { const txCert = await certificateDAL.create( @@ -400,7 +477,16 @@ export const certificateServiceFactory = ({ await certificateBodyDAL.create( { certId: txCert.id, - encryptedCertificate + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey }, tx ); @@ -435,11 +521,94 @@ export const certificateServiceFactory = ({ }; }; + /** + * Return certificate body and certificate chain for certificate with + * serial number [serialNumber] + */ + const getCertBundle = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBundleDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: cert.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: cert.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificate = certObj.toString("pem"); + + let certificateChain = null; + + // On newer certs the certBody.encryptedCertificateChain column will always exist. + // Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain. + if (certBody.encryptedCertificateChain) { + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } else if (cert.caCertId) { + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + certificate, + certificateChain, + privateKey: certPrivateKey, + serialNumber, + cert + }; + }; + return { getCert, + getCertPrivateKey, deleteCert, revokeCert, getCertBody, - importCert + importCert, + getCertBundle }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 79c2cc538..f1c79a36f 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -2,6 +2,10 @@ import * as x509 from "@peculiar/x509"; import { TProjectPermission } from "@app/lib/types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; + export enum CertStatus { ACTIVE = "active", REVOKED = "revoked" @@ -84,3 +88,19 @@ export type TImportCertDTO = { privateKeyPem: string; chainPem: string; } & Omit; + +export type TGetCertPrivateKeyDTO = { + serialNumber: string; +} & Omit; + +export type TGetCertBundleDTO = { + serialNumber: string; +} & Omit; + +export type TGetCertificateCredentialsDTO = { + certId: string; + projectId: string; + certificateSecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 66dee5331..6e17bf489 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -14,6 +14,7 @@ import { throwIfMissingSecretReadValueOrDescribePermission } from "@app/ee/servi import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, + ProjectPermissionCertificateActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -948,7 +949,10 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); const certificates = await certificateDAL.find( { diff --git a/docs/api-reference/endpoints/certificates/bundle.mdx b/docs/api-reference/endpoints/certificates/bundle.mdx new file mode 100644 index 000000000..5fbda7d96 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/bundle.mdx @@ -0,0 +1,8 @@ +--- +title: "Get Certificate Bundle" +openapi: "GET /api/v2/workspace/{slug}/bundle" +--- + + + You must have the certificate `read-private-key` permission in order to call this endpoint. + diff --git a/docs/api-reference/endpoints/certificates/private-key.mdx b/docs/api-reference/endpoints/certificates/private-key.mdx new file mode 100644 index 000000000..244aecea3 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/private-key.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Private Key" +openapi: "GET /api/v2/workspace/{slug}/private-key" +--- diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 4e0c592cb..acf95485b 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -252,11 +252,12 @@ Supports conditions and permission inversion #### Subject: `certificates` -| Action | Description | -| -------- | ----------------------------- | -| `read` | View certificates | -| `create` | Issue new certificates | -| `delete` | Revoke or remove certificates | +| Action | Description | +| -------------------- | ----------------------------- | +| `read` | View certificates | +| `read-private-key` | Read certificate private key | +| `create` | Issue new certificates | +| `delete` | Revoke or remove certificates | #### Subject: `certificate-templates` diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index 5bc163817..b195571f8 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; export { ProjectPermissionActions, + ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 71193dd6e..d1a257653 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -7,6 +7,14 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionCertificateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + ReadPrivateKey = "read-private-key" +} + export enum ProjectPermissionSecretActions { DescribeAndReadValue = "read", DescribeSecret = "describeSecret", @@ -268,7 +276,7 @@ export type ProjectPermissionSet = ) ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] - | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 51f2797d0..04af3c8a4 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -10,6 +10,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 74debfc96..1bfe86d33 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -73,6 +73,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_CERT]: "Delete certificate", [EventType.REVOKE_CERT]: "Revoke certificate", [EventType.GET_CERT_BODY]: "Get certificate body", + [EventType.GET_CERT_PRIVATE_KEY]: "Get certificate private key", + [EventType.GET_CERT_BUNDLE]: "Get certificate bundle", [EventType.CREATE_PKI_ALERT]: "Create PKI alert", [EventType.GET_PKI_ALERT]: "Get PKI alert", [EventType.UPDATE_PKI_ALERT]: "Update PKI alert", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 4781300d1..9916619bd 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -79,6 +79,8 @@ export enum EventType { DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", GET_CERT_BODY = "get-cert-body", + GET_CERT_PRIVATE_KEY = "get-cert-private-key", + GET_CERT_BUNDLE = "get-cert-bundle", CREATE_PKI_ALERT = "create-pki-alert", GET_PKI_ALERT = "get-pki-alert", UPDATE_PKI_ALERT = "update-pki-alert", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 770541aa5..838f500eb 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -628,6 +628,24 @@ interface GetCertBody { }; } +interface GetCertPrivateKey { + type: EventType.GET_CERT_PRIVATE_KEY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBundle { + type: EventType.GET_CERT_BUNDLE; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + interface CreatePkiAlert { type: EventType.CREATE_PKI_ALERT; metadata: { @@ -890,6 +908,8 @@ export type Event = | DeleteCert | RevokeCert | GetCertBody + | GetCertPrivateKey + | GetCertBundle | CreatePkiAlert | GetPkiAlert | UpdatePkiAlert diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 50c751c06..c53cef471 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -6,7 +6,8 @@ import { TCertificate } from "./types"; export const certKeys = { getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], - getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"] + getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"], + getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"] }; export const useGetCert = (serialNumber: string) => { @@ -38,3 +39,19 @@ export const useGetCertBody = (serialNumber: string) => { enabled: Boolean(serialNumber) }); }; + +export const useGetCertBundle = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertBundle(serialNumber), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + privateKey: string; + }>(`/api/v1/pki/certificates/${serialNumber}/bundle`); + return data; + }, + enabled: Boolean(serialNumber) + }); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx b/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx index c985f313f..4a4a22093 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx @@ -3,7 +3,12 @@ import { useTranslation } from "react-i18next"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionCertificateActions, + ProjectPermissionSub, + useProjectPermission +} from "@app/context"; import { PkiCollectionSection } from "../AlertingPage/components"; import { CertificatesSection } from "./components"; @@ -17,7 +22,7 @@ export const CertificatesPage = () => { ProjectPermissionSub.PkiCollections ); const canAccessCerts = permission.can( - ProjectPermissionActions.Read, + ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates ); @@ -40,7 +45,7 @@ export const CertificatesPage = () => { )} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx index 01c79589c..54620f1d6 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx @@ -1,5 +1,11 @@ import { Modal, ModalContent } from "@app/components/v2"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSub, + useProjectPermission +} from "@app/context"; import { useGetCertBody } from "@app/hooks/api"; +import { useGetCertBundle } from "@app/hooks/api/certificates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertificateContent } from "./CertificateContent"; @@ -10,10 +16,29 @@ type Props = { }; export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { - const { data } = useGetCertBody( - (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || "" + const { permission } = useProjectPermission(); + + const serialNumber = + (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || ""; + + const canReadPrivateKey = permission.can( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates ); + // useGetCertBundle fails unless user has the correct permissions + const { data: bundleData } = useGetCertBundle(serialNumber); + const { data: bodyData } = useGetCertBody(serialNumber); + + const data: + | { + certificate: string; + certificateChain: string; + serialNumber: string; + privateKey?: string; + } + | undefined = canReadPrivateKey ? bundleData : bodyData; + return ( { serialNumber={data.serialNumber} certificate={data.certificate} certificateChain={data.certificateChain} + privateKey={data.privateKey} /> ) : (
diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index a0c17db61..4eb4a4fb4 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -4,7 +4,11 @@ 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 { + ProjectPermissionCertificateActions, + ProjectPermissionSub, + useWorkspace +} from "@app/context"; import { useDeleteCert } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -52,7 +56,7 @@ export const CertificatesSection = () => {

Certificates

{(isAllowed) => ( diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index dcc832bfb..8cf7dda24 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -30,7 +30,11 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSub, + useWorkspace +} from "@app/context"; import { useListWorkspaceCertificates } from "@app/hooks/api"; import { CertStatus } from "@app/hooks/api/certificates/enums"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -110,7 +114,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(isAllowed) => ( @@ -131,7 +135,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { )} {(isAllowed) => ( @@ -152,7 +156,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { )} {(isAllowed) => ( @@ -173,7 +177,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { )} {(isAllowed) => ( diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx index f0388a2f4..6773f0658 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx @@ -114,7 +114,7 @@ export const GeneralPermissionPolicies = )}
-
+
Actions
{actions.map(({ label, value }, index) => { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 3a1bdb1d2..cd7b8cec3 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -6,6 +6,7 @@ import { z } from "zod"; import { Tooltip } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/context"; @@ -32,6 +33,14 @@ const GeneralPolicyActionSchema = z.object({ create: z.boolean().optional() }); +const CertificatePolicyActionSchema = z.object({ + [ProjectPermissionCertificateActions.Create]: z.boolean().optional(), + [ProjectPermissionCertificateActions.Delete]: z.boolean().optional(), + [ProjectPermissionCertificateActions.Edit]: z.boolean().optional(), + [ProjectPermissionCertificateActions.Read]: z.boolean().optional(), + [ProjectPermissionCertificateActions.ReadPrivateKey]: z.boolean().optional() +}); + const SecretPolicyActionSchema = z.object({ [ProjectPermissionSecretActions.DescribeAndReadValue]: z.boolean().optional(), // existing read, gives both describe and read value [ProjectPermissionSecretActions.DescribeSecret]: z.boolean().optional(), @@ -219,7 +228,7 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]), - [ProjectPermissionSub.Certificates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.Certificates]: CertificatePolicyActionSchema.array().default([]), [ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), @@ -371,7 +380,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.Certificates, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.CertificateTemplates, @@ -507,6 +515,25 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.Certificates) { + const canRead = action.includes(ProjectPermissionCertificateActions.Read); + const canEdit = action.includes(ProjectPermissionCertificateActions.Edit); + const canDelete = action.includes(ProjectPermissionCertificateActions.Delete); + const canCreate = action.includes(ProjectPermissionCertificateActions.Create); + const canReadPrivateKey = action.includes(ProjectPermissionCertificateActions.ReadPrivateKey); + + if (!formVal[subject]) formVal[subject] = [{}]; + + // from above statement we are sure it won't be undefined + if (canRead) formVal[subject]![0].read = true; + if (canEdit) formVal[subject]![0].edit = true; + if (canCreate) formVal[subject]![0].create = true; + if (canDelete) formVal[subject]![0].delete = true; + if (canReadPrivateKey) + formVal[subject]![0][ProjectPermissionCertificateActions.ReadPrivateKey] = true; + return; + } + if (subject === ProjectPermissionSub.Project) { const canEdit = action.includes(ProjectPermissionActions.Edit); const canDelete = action.includes(ProjectPermissionActions.Delete); @@ -1014,10 +1041,11 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { [ProjectPermissionSub.Certificates]: { title: "Certificates", actions: [ - { label: "Read", value: "read" }, - { label: "Create", value: "create" }, - { label: "Modify", value: "edit" }, - { label: "Remove", value: "delete" } + { label: "Read", value: ProjectPermissionCertificateActions.Read }, + { label: "Read Private Key", value: ProjectPermissionCertificateActions.ReadPrivateKey }, + { label: "Create", value: ProjectPermissionCertificateActions.Create }, + { label: "Modify", value: ProjectPermissionCertificateActions.Edit }, + { label: "Remove", value: ProjectPermissionCertificateActions.Delete } ] }, [ProjectPermissionSub.CertificateTemplates]: {