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/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts index f8cad74f1..75e6377b2 100644 --- a/backend/src/db/schemas/certificate-secrets.ts +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const CertificateSecretsSchema = z.object({ @@ -12,8 +14,7 @@ export const CertificateSecretsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - pk: z.string(), - sk: z.string() + encryptedPrivateKey: zodBuffer }); export type TCertificateSecrets = z.infer; diff --git a/backend/src/ee/routes/v1/secret-scanning-router.ts b/backend/src/ee/routes/v1/secret-scanning-router.ts index f144a6c00..1bc8e3998 100644 --- a/backend/src/ee/routes/v1/secret-scanning-router.ts +++ b/backend/src/ee/routes/v1/secret-scanning-router.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { GitAppOrgSchema, SecretScanningGitRisksSchema } from "@app/db/schemas"; +import { canUseSecretScanning } from "@app/ee/services/secret-scanning/secret-scanning-fns"; import { SecretScanningResolvedStatus, SecretScanningRiskStatus } from "@app/ee/services/secret-scanning/secret-scanning-types"; -import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -23,14 +23,14 @@ export const registerSecretScanningRouter = async (server: FastifyZodProvider) = body: z.object({ organizationId: z.string().trim() }), response: { 200: z.object({ - sessionId: z.string() + sessionId: z.string(), + gitAppSlug: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const appCfg = getConfig(); - if (!appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(req.auth.orgId)) { + if (!canUseSecretScanning(req.auth.orgId)) { throw new BadRequestError({ message: "Secret scanning is temporarily unavailable." }); 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 1f4badfb5..d7cad74be 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -224,6 +224,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", @@ -1790,6 +1792,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: { @@ -2824,6 +2844,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/ee/services/secret-scanning/secret-scanning-fns.ts b/backend/src/ee/services/secret-scanning/secret-scanning-fns.ts new file mode 100644 index 000000000..b1e2e0bbb --- /dev/null +++ b/backend/src/ee/services/secret-scanning/secret-scanning-fns.ts @@ -0,0 +1,11 @@ +import { getConfig } from "@app/lib/config/env"; + +export const canUseSecretScanning = (orgId: string) => { + const appCfg = getConfig(); + + if (!appCfg.isCloud) { + return true; + } + + return appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(orgId); +}; diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts index c5e7be9d8..7d41091fc 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -12,6 +12,7 @@ import { NotFoundError } from "@app/lib/errors"; import { TGitAppDALFactory } from "./git-app-dal"; import { TGitAppInstallSessionDALFactory } from "./git-app-install-session-dal"; import { TSecretScanningDALFactory } from "./secret-scanning-dal"; +import { canUseSecretScanning } from "./secret-scanning-fns"; import { TSecretScanningQueueFactory } from "./secret-scanning-queue"; import { SecretScanningRiskStatus, @@ -47,12 +48,14 @@ export const secretScanningServiceFactory = ({ actorAuthMethod, actorOrgId }: TInstallAppSessionDTO) => { + const appCfg = getConfig(); + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); const sessionId = crypto.randomBytes(16).toString("hex"); await gitAppInstallSessionDAL.upsert({ orgId, sessionId, userId: actorId }); - return { sessionId }; + return { sessionId, gitAppSlug: appCfg.SECRET_SCANNING_GIT_APP_SLUG }; }; const linkInstallationToOrg = async ({ @@ -91,7 +94,8 @@ export const secretScanningServiceFactory = ({ const { data: { repositories } } = await octokit.apps.listReposAccessibleToInstallation(); - if (appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(actorOrgId)) { + + if (canUseSecretScanning(actorOrgId)) { await Promise.all( repositories.map(({ id, full_name }) => secretScanningQueue.startFullRepoScan({ @@ -102,6 +106,7 @@ export const secretScanningServiceFactory = ({ ) ); } + return { installatedApp }; }; @@ -164,7 +169,6 @@ export const secretScanningServiceFactory = ({ }; const handleRepoPushEvent = async (payload: WebhookEventMap["push"]) => { - const appCfg = getConfig(); const { commits, repository, installation, pusher } = payload; if (!commits || !repository || !installation || !pusher) { return; @@ -175,7 +179,7 @@ export const secretScanningServiceFactory = ({ }); if (!installationLink) return; - if (appCfg.SECRET_SCANNING_ORG_WHITELIST?.includes(installationLink.orgId)) { + if (canUseSecretScanning(installationLink.orgId)) { await secretScanningQueue.startPushEventScan({ commits, pusher: { name: pusher.name, email: pusher.email }, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 10454ab9b..ae6bbbcab 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." } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 907884433..e38dbcfb5 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -146,6 +146,7 @@ const envSchema = z SECRET_SCANNING_GIT_APP_ID: zpStr(z.string().optional()), SECRET_SCANNING_PRIVATE_KEY: zpStr(z.string().optional()), SECRET_SCANNING_ORG_WHITELIST: zpStr(z.string().optional()), + SECRET_SCANNING_GIT_APP_SLUG: zpStr(z.string().default("infisical-radar")), // LICENSE LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")), LICENSE_SERVER_KEY: zpStr(z.string().optional()), 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 e10f49794..e3e5ffc2a 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"; @@ -819,6 +820,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); @@ -827,6 +829,7 @@ export const registerRoutes = async ( const certificateService = certificateServiceFactory({ certificateDAL, certificateBodyDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthorityCrlDAL, @@ -898,6 +901,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 ea33e948f..dad1d9a80 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 { ca, 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: ca.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().nullish().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, ca, 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: ca.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", @@ -411,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), - certificateChain: z.string().trim().describe(CERTIFICATES.GET_CERT.certificateChain), + certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain), serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) }) } @@ -429,7 +536,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId: ca.projectId, event: { - type: EventType.DELETE_CERT, + type: EventType.GET_CERT_BODY, metadata: { certId: cert.id, cn: cert.commonName, diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 499a25741..e1d7ce5cb 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( @@ -1396,7 +1423,16 @@ export const certificateAuthorityServiceFactory = ({ await certificateBodyDAL.create( { certId: cert.id, - encryptedCertificate + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey }, tx ); @@ -1414,17 +1450,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, @@ -1487,7 +1515,7 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionCertificateActions.Create, ProjectPermissionSub.Certificates ); } diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index 45ad5963c..961fb27ff 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, TBuildCertificateChainDTO, TGetCertificateCredentialsDTO } from "./certificate-types"; export const revocationReasonToCrlCode = (crlReason: CrlReason) => { switch (crlReason) { @@ -46,3 +51,73 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[]) .map((cert) => cert.toString("pem")) .join("\n") .trim(); + +/** + * 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}'` }); + } +}; + +// If the certificate was generated after ~05/01/25 it will have a encryptedCertificateChain attached to it's body +// Otherwise we'll fallback to manually building the chain +export const buildCertificateChain = async ({ + caCert, + caCertChain, + encryptedCertificateChain, + kmsService, + kmsId +}: TBuildCertificateChainDTO) => { + if (!encryptedCertificateChain && (!caCert || !caCertChain)) { + return null; + } + + let certificateChain = `${caCert}\n${caCertChain}`.trim(); + + if (encryptedCertificateChain) { + const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId }); + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: encryptedCertificateChain + }); + certificateChain = decryptedCertChain.toString(); + } + + return certificateChain; +}; 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 0ca0d64c6..73a8caed7 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -4,7 +4,10 @@ import * as x509 from "@peculiar/x509"; 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -15,11 +18,21 @@ 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 } from "./certificate-fns"; -import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; +import { buildCertificateChain, getCertificateCredentials, revocationReasonToCrlCode } from "./certificate-fns"; +import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; +import { + CertStatus, + TDeleteCertDTO, + TGetCertBodyDTO, + TGetCertBundleDTO, + TGetCertDTO, + TGetCertPrivateKeyDTO, + TRevokeCertDTO +} from "./certificate-types"; type TCertificateServiceFactoryDep = { certificateDAL: Pick; + certificateSecretDAL: Pick; certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; @@ -34,6 +47,7 @@ export type TCertificateServiceFactory = ReturnType { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: ca.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + ca, + cert, + certPrivateKey + }; + }; + /** * Delete certificate with serial number [serialNumber] */ @@ -83,7 +142,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); @@ -118,7 +180,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"); @@ -165,7 +230,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 }); @@ -192,19 +260,107 @@ export const certificateServiceFactory = ({ kmsService }); + const certificateChain = await buildCertificateChain({ + caCert, + caCertChain, + kmsId: certificateManagerKeyId, + kmsService, + encryptedCertificateChain: certBody.encryptedCertificateChain || undefined + }); + return { certificate: certObj.toString("pem"), - certificateChain: `${caCert}\n${caCertChain}`.trim(), + certificateChain, serialNumber: certObj.serialNumber, cert, ca }; }; + /** + * 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 ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.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: ca.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"); + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: cert.caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChain = await buildCertificateChain({ + caCert, + caCertChain, + kmsId: certificateManagerKeyId, + kmsService, + encryptedCertificateChain: certBody.encryptedCertificateChain || undefined + }); + + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: ca.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + + return { + certificate, + certificateChain, + privateKey: certPrivateKey, + serialNumber, + cert, + ca + }; + }; + return { getCert, + getCertPrivateKey, deleteCert, revokeCert, - getCertBody + getCertBody, + getCertBundle }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index ef63f142d..ae04eae6b 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" @@ -73,3 +77,27 @@ export type TRevokeCertDTO = { export type TGetCertBodyDTO = { serialNumber: 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; +}; + +export type TBuildCertificateChainDTO = { + caCert?: string; + caCertChain?: string; + encryptedCertificateChain?: Buffer; + kmsService: Pick; + kmsId: string; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 8e60252ba..ecc8c5a36 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 cas = await certificateAuthorityDAL.find({ projectId }); 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/documentation/platform/secret-scanning.mdx b/docs/documentation/platform/secret-scanning.mdx index 4f030e882..da28bfa55 100644 --- a/docs/documentation/platform/secret-scanning.mdx +++ b/docs/documentation/platform/secret-scanning.mdx @@ -7,6 +7,113 @@ The Infisical Secret Scanner allows you to keep an overview and stay alert of ex To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes. + + + + To setup secret scanning on your own instance of Infisical, you can follow the steps below. + + + + Create a new GitHub app in your GitHub organization or personal [Developer Settings](https://github.com/settings/apps). + + ![Create GitHub App](/images/platform/secret-scanning/github-create-app.png) + + ### Configure the GitHub App + To configure the GitHub app to work with Infisical, you'll need to modify the following settings: + - **Homepage URL**: Required to be set. Set it to the URL of your Infisical instance. (e.g. `https://app.infisical.com`) + - **Setup URL**: Set this to `https:///organization/secret-scanning` + - **Webhook URL**: Set this to `https:///api/v1/secret-scanning/webhook` + - **Webhook Secret**: Set this to a random string. This is used to verify the webhook request from Infisical. Use `openssl rand -base64 32` in your terminal to generate a random secret. + + + Remember to save the webhook secret as you will need it in the next step. + + + ![GitHub App Settings](/images/platform/secret-scanning/github-configure-app.png) + + ### Configure the GitHub App Permissions + The GitHub app needs the following permissions: + + Repository permissions: + - `Checks`: Read and Write + - `Contents`: Read-only + - `Issues`: Read and Write + - `Pull Requests`: Read and Write + - `Metadata`: Read-only (enabled by default) + + ![Github App Repository Permissions](/images/platform/secret-scanning/github-repo-permissions.png) + + Subscribed events: + - `Check run` + - `Pull request` + - `Push` + + ![Github App Subscribed Events](/images/platform/secret-scanning/github-subscribed-events.png) + + + ### Create the GitHub App + Now you can create the GitHub app by clicking on the "Create GitHub App" button. + + + If you want other Github users to be able to install the app, you need to tick the "Any account" option under "Where can this GitHub App be installed?" + + + ![Create GitHub App](/images/platform/secret-scanning/github-create-app-button.png) + + + + After clicking the "Create GitHub App" button, you will be redirected to the GitHub settings page. Here you can copy the "App ID" and save it for later when you need to configure your environment variables for your Infisical instance. + + ![Github App ID](/images/platform/secret-scanning/github-app-copy-app-id.png) + + + + The GitHub App slug is the name of the app you created in a slug friendly format. You can find the slug in the URL of the app you created. + + ![Github App Slug](/images/platform/secret-scanning/github-app-copy-slug.png) + + + + Create a new app private key by clicking on the "Generate a private key" button under the "Private keys" section. + + Once you click the "Generate a private key" button, the private key will be downloaded to your computer. Save this file for later as you will need the private key when configuring Infisical. + + ![Github App Private Key](/images/platform/secret-scanning/github-app-create-private-key.png) + + + Remember to save the private key as you will need it in the next step. + + + + + + + Now you can configure your Infisical instance by setting the following environment variables: + + - `SECRET_SCANNING_GIT_APP_ID`: The App ID of your GitHub App. + - `SECRET_SCANNING_GIT_APP_SLUG`: The slug of your GitHub App. + - `SECRET_SCANNING_PRIVATE_KEY`: The private key of your GitHub App that you created in a previous step. + - `SECRET_SCANNING_WEBHOOK_SECRET`: The webhook secret of your GitHub App that you created in a previous step. + + + + After restarting your Infisical instance, you should be able to use the secret scanning feature within your organization. Follow the steps below to add the GitHub App to your Infisical organization. + + +## Install the Infisical Radar GitHub App + +To install the GitHub App, press the "Integrate With GitHub" button in the top right corner of your Infisical Secret Scanning dashboard. + +![Integrate With GitHub](/images/platform/secret-scanning/infisical-connect-secret-scanner.png) + +Next, you'll be prompted to select which organization you'd like to install the app into. Select the organization you'd like to install the app into by clicking the organization in the menu. + +![Select Organization](/images/platform/secret-scanning/github-select-org-2.png) + +Select the repositories you'd like to scan for secrets and press the "Install" button. + +![Select Repositories](/images/platform/secret-scanning/github-select-repos.png) + ## Code Scanning ![Scanning Overview](/images/platform/secret-scanning/overview.png) diff --git a/docs/images/platform/secret-scanning/github-app-copy-app-id.png b/docs/images/platform/secret-scanning/github-app-copy-app-id.png new file mode 100644 index 000000000..a94cb5ece Binary files /dev/null and b/docs/images/platform/secret-scanning/github-app-copy-app-id.png differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-slug.png b/docs/images/platform/secret-scanning/github-app-copy-slug.png new file mode 100644 index 000000000..c555dcd41 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-app-copy-slug.png differ diff --git a/docs/images/platform/secret-scanning/github-app-create-private-key.png b/docs/images/platform/secret-scanning/github-app-create-private-key.png new file mode 100644 index 000000000..50f602a36 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-app-create-private-key.png differ diff --git a/docs/images/platform/secret-scanning/github-configure-app.png b/docs/images/platform/secret-scanning/github-configure-app.png new file mode 100644 index 000000000..df64eeb18 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-configure-app.png differ diff --git a/docs/images/platform/secret-scanning/github-create-app-button.png b/docs/images/platform/secret-scanning/github-create-app-button.png new file mode 100644 index 000000000..3ea4b2d38 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-create-app-button.png differ diff --git a/docs/images/platform/secret-scanning/github-create-app.png b/docs/images/platform/secret-scanning/github-create-app.png new file mode 100644 index 000000000..f4d1cdb8c Binary files /dev/null and b/docs/images/platform/secret-scanning/github-create-app.png differ diff --git a/docs/images/platform/secret-scanning/github-register-app.png b/docs/images/platform/secret-scanning/github-register-app.png new file mode 100644 index 000000000..904c07bf2 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-register-app.png differ diff --git a/docs/images/platform/secret-scanning/github-repo-permissions.png b/docs/images/platform/secret-scanning/github-repo-permissions.png new file mode 100644 index 000000000..53eae9a41 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-repo-permissions.png differ diff --git a/docs/images/platform/secret-scanning/github-select-org-2.png b/docs/images/platform/secret-scanning/github-select-org-2.png new file mode 100644 index 000000000..55b945c18 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-select-org-2.png differ diff --git a/docs/images/platform/secret-scanning/github-select-org.png b/docs/images/platform/secret-scanning/github-select-org.png new file mode 100644 index 000000000..7d6e5abc5 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-select-org.png differ diff --git a/docs/images/platform/secret-scanning/github-select-repos.png b/docs/images/platform/secret-scanning/github-select-repos.png new file mode 100644 index 000000000..51a6648d2 Binary files /dev/null and b/docs/images/platform/secret-scanning/github-select-repos.png differ diff --git a/docs/images/platform/secret-scanning/github-subscribed-events.png b/docs/images/platform/secret-scanning/github-subscribed-events.png new file mode 100644 index 000000000..7aa6b431f Binary files /dev/null and b/docs/images/platform/secret-scanning/github-subscribed-events.png differ diff --git a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png b/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png new file mode 100644 index 000000000..11f24fd74 Binary files /dev/null and b/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png differ diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx index b823e6246..e45de05bf 100644 --- a/docs/internals/bug-bounty.mdx +++ b/docs/internals/bug-bounty.mdx @@ -41,7 +41,7 @@ All final reward amounts are determined at Infisical's discretion based on impac ### Out of Scope -- Social engineering or phishing +- Social engineering or phishing (including email hyperlink injection without code execution) - Rate limiting issues on non-sensitive endpoints - Denial-of-service attacks that require authentication and don't impact core service availability - Findings based on outdated or forked code not maintained by the Infisical team @@ -57,4 +57,24 @@ We ask that researchers: - Use testing accounts where possible - Give us a reasonable window to investigate and patch before going public -Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. \ No newline at end of file +Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. + +### Program Conduct and Enforcement + +We value professional and collaborative interaction with security researchers. To maintain the integrity of our bug bounty program, we expect all participants to adhere to the following guidelines: + +- Maintain professional communication in all interactions +- Do not threaten public disclosure of vulnerabilities before we've had reasonable time to investigate and address the issue +- Do not attempt to extort or coerce compensation through threats +- Follow the responsible disclosure process outlined in this document +- Do not use automated scanning tools without prior permission + +Violations of these guidelines may result in: + +1. **Warning**: For minor violations, we may issue a warning explaining the violation and requesting compliance with program guidelines. +2. **Temporary Ban**: Repeated minor violations or more serious violations may result in a temporary suspension from the program. +3. **Permanent Ban**: Severe violations such as threats, extortion attempts, or unauthorized public disclosure will result in permanent removal from the Infisical Bug Bounty Program. + +We reserve the right to reject reports, withhold bounties, and remove participants from the program at our discretion for conduct that undermines the collaborative spirit of security research. + +Infisical is committed to working respectfully with security researchers who follow these guidelines, and we strive to recognize and reward valuable contributions that help protect our platform and users. 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/docs/mint.json b/docs/mint.json index a25a70124..54039c5ba 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1454,6 +1454,8 @@ "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", "api-reference/endpoints/certificates/cert-body", + "api-reference/endpoints/certificates/bundle", + "api-reference/endpoints/certificates/private-key", "api-reference/endpoints/certificates/issue-certificate", "api-reference/endpoints/certificates/sign-certificate" ] diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index d9eef9cb0..b63c58d3a 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -29,6 +29,19 @@ Used to configure platform-specific security and operational settings Specifies the internal port on which the application listens. + + Specifies the network interface Infisical will bind to when accepting incoming connections. + + By default, Infisical binds to `localhost`, which restricts access to connections from the same machine. + + To make the application accessible externally (e.g., for self-hosted deployments), set this to `0.0.0.0`, which tells the server to listen on all network interfaces. + + Example values: + - `localhost` (default, same as `127.0.0.1`) + - `0.0.0.0` (all interfaces, accessible externally) + - `192.168.1.100` (specific interface IP) + + Telemetry helps us improve Infisical but if you want to disable it you may set this to `false`. @@ -612,6 +625,26 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I +## Secret Scanning + + + + The App ID of your GitHub App. + + + + The slug of your GitHub App. + + + + A private key for your GitHub App. + + + + The webhook secret of your GitHub App. + + + ## Observability You can configure Infisical to collect and expose telemetry data for analytics and monitoring. 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 9de5c4085..36daed4b7 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -72,6 +72,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 08f2559f6..ac57def4f 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -78,6 +78,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 440f25c3d..745d0368f 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -620,6 +620,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: { @@ -881,6 +899,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/hooks/api/secretScanning/mutation.ts b/frontend/src/hooks/api/secretScanning/mutation.ts index 7298b9af1..5055da4aa 100644 --- a/frontend/src/hooks/api/secretScanning/mutation.ts +++ b/frontend/src/hooks/api/secretScanning/mutation.ts @@ -10,15 +10,17 @@ import { } from "./types"; export const useCreateNewInstallationSession = () => { - return useMutation<{ sessionId: string }, object, { organizationId: string }>({ - mutationFn: async (opt) => { - const { data } = await apiRequest.post( - "/api/v1/secret-scanning/create-installation-session/organization", - opt - ); - return data; + return useMutation<{ sessionId: string; gitAppSlug: string }, object, { organizationId: string }>( + { + mutationFn: async (opt) => { + const { data } = await apiRequest.post( + "/api/v1/secret-scanning/create-installation-session/organization", + opt + ); + return data; + } } - }); + ); }; export const useUpdateRiskStatus = () => { 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 d5f94e7b7..ef8b09dac 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"; @@ -50,7 +54,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/organization/SecretScanningPage/SecretScanningPage.tsx b/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx index ba919cdd3..23cdcf800 100644 --- a/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx +++ b/frontend/src/pages/organization/SecretScanningPage/SecretScanningPage.tsx @@ -108,7 +108,7 @@ export const SecretScanningPage = withPermission( const generateNewIntegrationSession = async () => { const session = await createNewIntegrationSession({ organizationId }); - window.location.href = `https://github.com/apps/infisical-radar/installations/new?state=${session.sessionId}`; + window.location.href = `https://github.com/apps/${session.gitAppSlug}/installations/new?state=${session.sessionId}`; }; return ( 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]: {