diff --git a/backend/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index f4f251616..91b64ff0d 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -8,6 +8,9 @@ import { Lock } from "@app/lib/red-lock"; export const mockKeyStore = (): TKeyStoreFactory => { const store: Record = {}; + const getRegex = (pattern: string) => + new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); + return { setItem: async (key, value) => { store[key] = value; @@ -23,7 +26,7 @@ export const mockKeyStore = (): TKeyStoreFactory => { return 1; }, deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => { - const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); + const regex = getRegex(pattern); let totalDeleted = 0; const keys = Object.keys(store); @@ -53,6 +56,27 @@ export const mockKeyStore = (): TKeyStoreFactory => { incrementBy: async () => { return 1; }, + getItems: async (keys) => { + const values = keys.map((key) => { + const value = store[key]; + if (typeof value === "string") { + return value; + } + return null; + }); + return values; + }, + getKeysByPattern: async (pattern) => { + const regex = getRegex(pattern); + const keys = Object.keys(store); + return keys.filter((key) => regex.test(key)); + }, + deleteItemsByKeyIn: async (keys) => { + for (const key of keys) { + delete store[key]; + } + return keys.length; + }, acquireLock: () => { return Promise.resolve({ release: () => {} diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4fe4e17cf..2956be192 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -74,6 +74,7 @@ import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-a import { TIdentityOciAuthServiceFactory } from "@app/services/identity-oci-auth/identity-oci-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types"; import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; @@ -218,6 +219,7 @@ declare module "fastify" { identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAliCloudAuth: TIdentityAliCloudAuthServiceFactory; + identityTlsCertAuth: TIdentityTlsCertAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOciAuth: TIdentityOciAuthServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1d4ad1797..7ead9f84b 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -164,6 +164,9 @@ import { TIdentityProjectMemberships, TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsUpdate, + TIdentityTlsCertAuths, + TIdentityTlsCertAuthsInsert, + TIdentityTlsCertAuthsUpdate, TIdentityTokenAuths, TIdentityTokenAuthsInsert, TIdentityTokenAuthsUpdate, @@ -794,6 +797,11 @@ declare module "knex/types/tables" { TIdentityAlicloudAuthsInsert, TIdentityAlicloudAuthsUpdate >; + [TableName.IdentityTlsCertAuth]: KnexOriginal.CompositeTableType< + TIdentityTlsCertAuths, + TIdentityTlsCertAuthsInsert, + TIdentityTlsCertAuthsUpdate + >; [TableName.IdentityAwsAuth]: KnexOriginal.CompositeTableType< TIdentityAwsAuths, TIdentityAwsAuthsInsert, diff --git a/backend/src/db/migrations/20250624061429_identity-tls-auth.ts b/backend/src/db/migrations/20250624061429_identity-tls-auth.ts new file mode 100644 index 000000000..3e6dc1af8 --- /dev/null +++ b/backend/src/db/migrations/20250624061429_identity-tls-auth.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityTlsCertAuth))) { + await knex.schema.createTable(TableName.IdentityTlsCertAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("allowedCommonNames").nullable(); + t.binary("encryptedCaCertificate").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityTlsCertAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityTlsCertAuth); +} diff --git a/backend/src/db/schemas/identity-tls-cert-auths.ts b/backend/src/db/schemas/identity-tls-cert-auths.ts new file mode 100644 index 000000000..c907ead73 --- /dev/null +++ b/backend/src/db/schemas/identity-tls-cert-auths.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityTlsCertAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + allowedCommonNames: z.string().nullable().optional(), + encryptedCaCertificate: zodBuffer +}); + +export type TIdentityTlsCertAuths = z.infer; +export type TIdentityTlsCertAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityTlsCertAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 292551c80..1642c3555 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -52,6 +52,7 @@ export * from "./identity-org-memberships"; export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; +export * from "./identity-tls-cert-auths"; export * from "./identity-token-auths"; export * from "./identity-ua-client-secrets"; export * from "./identity-universal-auths"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index ceba6e370..d47962110 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -86,6 +86,7 @@ export enum TableName { IdentityOidcAuth = "identity_oidc_auths", IdentityJwtAuth = "identity_jwt_auths", IdentityLdapAuth = "identity_ldap_auths", + IdentityTlsCertAuth = "identity_tls_cert_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -251,6 +252,7 @@ export enum IdentityAuthMethod { ALICLOUD_AUTH = "alicloud-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", + TLS_CERT_AUTH = "tls-cert-auth", OCI_AUTH = "oci-auth", OIDC_AUTH = "oidc-auth", JWT_AUTH = "jwt-auth", diff --git a/backend/src/ee/routes/v1/ssh-certificate-router.ts b/backend/src/ee/routes/v1/ssh-certificate-router.ts index cb576e496..4706e992d 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-router.ts @@ -80,6 +80,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SignSshKey, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { certificateTemplateId: req.body.certificateTemplateId, principals: req.body.principals, @@ -171,6 +172,7 @@ export const registerSshCertRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueSshCreds, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { certificateTemplateId: req.body.certificateTemplateId, principals: req.body.principals, diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 4c749f6f5..50512a292 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -358,6 +358,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueSshHostUserCert, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { sshHostId: req.params.sshHostId, hostname: host.hostname, @@ -427,6 +428,7 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueSshHostHostCert, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { sshHostId: req.params.sshHostId, diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index e812cb7ea..5cd1f507e 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -131,7 +131,6 @@ export const auditLogQueueServiceFactory = async ({ }); try { - logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`); const response = await request.post( url, { ...providerSpecificPayload(url), ...auditLog }, @@ -143,9 +142,6 @@ export const auditLogQueueServiceFactory = async ({ signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) } ); - logger.info( - `Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]` - ); return response; } catch (error) { logger.error( @@ -237,7 +233,6 @@ export const auditLogQueueServiceFactory = async ({ }); try { - logger.info(`Streaming audit log [url=${url}] for org [orgId=${orgId}]`); const response = await request.post( url, { ...providerSpecificPayload(url), ...auditLog }, @@ -249,9 +244,6 @@ export const auditLogQueueServiceFactory = async ({ signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) } ); - logger.info( - `Successfully streamed audit log [url=${url}] for org [orgId=${orgId}] [response=${JSON.stringify(response.data)}]` - ); return response; } catch (error) { logger.error( 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 e72b9fa46..a2acb62c4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -202,6 +202,12 @@ export enum EventType { REVOKE_IDENTITY_ALICLOUD_AUTH = "revoke-identity-alicloud-auth", GET_IDENTITY_ALICLOUD_AUTH = "get-identity-alicloud-auth", + LOGIN_IDENTITY_TLS_CERT_AUTH = "login-identity-tls-cert-auth", + ADD_IDENTITY_TLS_CERT_AUTH = "add-identity-tls-cert-auth", + UPDATE_IDENTITY_TLS_CERT_AUTH = "update-identity-tls-cert-auth", + REVOKE_IDENTITY_TLS_CERT_AUTH = "revoke-identity-tls-cert-auth", + GET_IDENTITY_TLS_CERT_AUTH = "get-identity-tls-cert-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", @@ -1141,6 +1147,53 @@ interface GetIdentityAliCloudAuthEvent { }; } +interface LoginIdentityTlsCertAuthEvent { + type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH; + metadata: { + identityId: string; + identityTlsCertAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityTlsCertAuthEvent { + type: EventType.ADD_IDENTITY_TLS_CERT_AUTH; + metadata: { + identityId: string; + allowedCommonNames: string | null | undefined; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface DeleteIdentityTlsCertAuthEvent { + type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH; + metadata: { + identityId: string; + }; +} + +interface UpdateIdentityTlsCertAuthEvent { + type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH; + metadata: { + identityId: string; + allowedCommonNames: string | null | undefined; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityTlsCertAuthEvent { + type: EventType.GET_IDENTITY_TLS_CERT_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOciAuthEvent { type: EventType.LOGIN_IDENTITY_OCI_AUTH; metadata: { @@ -3358,6 +3411,11 @@ export type Event = | UpdateIdentityAliCloudAuthEvent | GetIdentityAliCloudAuthEvent | DeleteIdentityAliCloudAuthEvent + | LoginIdentityTlsCertAuthEvent + | AddIdentityTlsCertAuthEvent + | UpdateIdentityTlsCertAuthEvent + | GetIdentityTlsCertAuthEvent + | DeleteIdentityTlsCertAuthEvent | LoginIdentityOciAuthEvent | AddIdentityOciAuthEvent | UpdateIdentityOciAuthEvent diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts index 42ff90055..87e39e5b4 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue.ts @@ -1,17 +1,11 @@ -import { ProbotOctokit } from "probot"; - -import { OrgMembershipRole, TableName } from "@app/db/schemas"; -import { getConfig } from "@app/lib/config/env"; -import { logger } from "@app/lib/logger"; -import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { InternalServerError } from "@app/lib/errors"; +import { TQueueServiceFactory } from "@app/queue"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TSmtpService } from "@app/services/smtp/smtp-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; -import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { TSecretScanningDALFactory } from "../secret-scanning-dal"; -import { scanContentAndGetFindings, scanFullRepoContentAndGetFindings } from "./secret-scanning-fns"; -import { SecretMatch, TScanFullRepoEventPayload, TScanPushEventPayload } from "./secret-scanning-queue-types"; +import { TScanFullRepoEventPayload, TScanPushEventPayload } from "./secret-scanning-queue-types"; type TSecretScanningQueueFactoryDep = { queueService: TQueueServiceFactory; @@ -23,227 +17,21 @@ type TSecretScanningQueueFactoryDep = { export type TSecretScanningQueueFactory = ReturnType; -export const secretScanningQueueFactory = ({ - queueService, - secretScanningDAL, - smtpService, - telemetryService, - orgMembershipDAL: orgMemberDAL -}: TSecretScanningQueueFactoryDep) => { - const startFullRepoScan = async (payload: TScanFullRepoEventPayload) => { - await queueService.queue(QueueName.SecretFullRepoScan, QueueJobs.SecretScan, payload, { - attempts: 3, - backoff: { - type: "exponential", - delay: 5000 - }, - removeOnComplete: true, - removeOnFail: { - count: 20 // keep the most recent 20 jobs - } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const secretScanningQueueFactory = (_props: TSecretScanningQueueFactoryDep) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const startFullRepoScan = async (_payload: TScanFullRepoEventPayload) => { + throw new InternalServerError({ + message: "Secret Scanning V1 has been deprecated. Please migrate to Secret Scanning V2" }); }; - const startPushEventScan = async (payload: TScanPushEventPayload) => { - await queueService.queue(QueueName.SecretPushEventScan, QueueJobs.SecretScan, payload, { - attempts: 3, - backoff: { - type: "exponential", - delay: 5000 - }, - removeOnComplete: true, - removeOnFail: { - count: 20 // keep the most recent 20 jobs - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const startPushEventScan = async (_payload: TScanPushEventPayload) => { + throw new InternalServerError({ + message: "Secret Scanning V1 has been deprecated. Please migrate to Secret Scanning V2" }); }; - const getOrgAdminEmails = async (organizationId: string) => { - // get emails of admins - const adminsOfWork = await orgMemberDAL.findMembership({ - [`${TableName.Organization}.id` as string]: organizationId, - role: OrgMembershipRole.Admin - }); - return adminsOfWork.filter((userObject) => userObject.email).map((userObject) => userObject.email as string); - }; - - queueService.start(QueueName.SecretPushEventScan, async (job) => { - const appCfg = getConfig(); - const { organizationId, commits, pusher, repository, installationId } = job.data; - const [owner, repo] = repository.fullName.split("/"); - const octokit = new ProbotOctokit({ - auth: { - appId: appCfg.SECRET_SCANNING_GIT_APP_ID, - privateKey: appCfg.SECRET_SCANNING_PRIVATE_KEY, - installationId - } - }); - const allFindingsByFingerprint: { [key: string]: SecretMatch } = {}; - - for (const commit of commits) { - for (const filepath of [...commit.added, ...commit.modified]) { - // eslint-disable-next-line - const fileContentsResponse = await octokit.repos.getContent({ - owner, - repo, - path: filepath - }); - - const { data } = fileContentsResponse; - const fileContent = Buffer.from((data as { content: string }).content, "base64").toString(); - - // eslint-disable-next-line - const findings = await scanContentAndGetFindings(`\n${fileContent}`); // extra line to count lines correctly - - for (const finding of findings) { - const fingerPrintWithCommitId = `${commit.id}:${filepath}:${finding.RuleID}:${finding.StartLine}`; - const fingerPrintWithoutCommitId = `${filepath}:${finding.RuleID}:${finding.StartLine}`; - finding.Fingerprint = fingerPrintWithCommitId; - finding.FingerPrintWithoutCommitId = fingerPrintWithoutCommitId; - finding.Commit = commit.id; - finding.File = filepath; - finding.Author = commit.author.name; - finding.Email = commit?.author?.email ? commit?.author?.email : ""; - - allFindingsByFingerprint[fingerPrintWithCommitId] = finding; - } - } - } - await secretScanningDAL.transaction(async (tx) => { - if (!Object.keys(allFindingsByFingerprint).length) return; - await secretScanningDAL.upsert( - Object.keys(allFindingsByFingerprint).map((key) => ({ - installationId, - email: allFindingsByFingerprint[key].Email, - author: allFindingsByFingerprint[key].Author, - date: allFindingsByFingerprint[key].Date, - file: allFindingsByFingerprint[key].File, - tags: allFindingsByFingerprint[key].Tags, - commit: allFindingsByFingerprint[key].Commit, - ruleID: allFindingsByFingerprint[key].RuleID, - endLine: String(allFindingsByFingerprint[key].EndLine), - entropy: String(allFindingsByFingerprint[key].Entropy), - message: allFindingsByFingerprint[key].Message, - endColumn: String(allFindingsByFingerprint[key].EndColumn), - startLine: String(allFindingsByFingerprint[key].StartLine), - startColumn: String(allFindingsByFingerprint[key].StartColumn), - fingerPrintWithoutCommitId: allFindingsByFingerprint[key].FingerPrintWithoutCommitId, - description: allFindingsByFingerprint[key].Description, - symlinkFile: allFindingsByFingerprint[key].SymlinkFile, - orgId: organizationId, - pusherEmail: pusher.email, - pusherName: pusher.name, - repositoryFullName: repository.fullName, - repositoryId: String(repository.id), - fingerprint: allFindingsByFingerprint[key].Fingerprint - })), - tx - ); - }); - - const adminEmails = await getOrgAdminEmails(organizationId); - if (pusher?.email) { - adminEmails.push(pusher.email); - } - if (Object.keys(allFindingsByFingerprint).length) { - await smtpService.sendMail({ - template: SmtpTemplates.SecretLeakIncident, - subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails.filter((email) => email).map((email) => email), - substitutions: { - numberOfSecrets: Object.keys(allFindingsByFingerprint).length, - pusher_email: pusher.email, - pusher_name: pusher.name - } - }); - } - - await telemetryService.sendPostHogEvents({ - event: PostHogEventTypes.SecretScannerPush, - distinctId: repository.fullName, - properties: { - numberOfRisks: Object.keys(allFindingsByFingerprint).length - } - }); - }); - - queueService.start(QueueName.SecretFullRepoScan, async (job) => { - const appCfg = getConfig(); - const { organizationId, repository, installationId } = job.data; - const octokit = new ProbotOctokit({ - auth: { - appId: appCfg.SECRET_SCANNING_GIT_APP_ID, - privateKey: appCfg.SECRET_SCANNING_PRIVATE_KEY, - installationId - } - }); - - const findings = await scanFullRepoContentAndGetFindings( - // this is because of collision of octokit in probot and github - // eslint-disable-next-line - octokit as any, - installationId, - repository.fullName - ); - await secretScanningDAL.transaction(async (tx) => { - if (!findings.length) return; - // eslint-disable-next-line - await secretScanningDAL.upsert( - findings.map((finding) => ({ - installationId, - email: finding.Email, - author: finding.Author, - date: finding.Date, - file: finding.File, - tags: finding.Tags, - commit: finding.Commit, - ruleID: finding.RuleID, - endLine: String(finding.EndLine), - entropy: String(finding.Entropy), - message: finding.Message, - endColumn: String(finding.EndColumn), - startLine: String(finding.StartLine), - startColumn: String(finding.StartColumn), - fingerPrintWithoutCommitId: finding.FingerPrintWithoutCommitId, - description: finding.Description, - symlinkFile: finding.SymlinkFile, - orgId: organizationId, - repositoryFullName: repository.fullName, - repositoryId: String(repository.id), - fingerprint: finding.Fingerprint - })), - tx - ); - }); - - const adminEmails = await getOrgAdminEmails(organizationId); - if (findings.length) { - await smtpService.sendMail({ - template: SmtpTemplates.SecretLeakIncident, - subjectLine: `Incident alert: leaked secrets found in Github repository ${repository.fullName}`, - recipients: adminEmails.filter((email) => email).map((email) => email), - substitutions: { - numberOfSecrets: findings.length - } - }); - } - - await telemetryService.sendPostHogEvents({ - event: PostHogEventTypes.SecretScannerFull, - distinctId: repository.fullName, - properties: { - numberOfRisks: findings.length - } - }); - }); - - queueService.listen(QueueName.SecretPushEventScan, "failed", (job, err) => { - logger.error(err, "Failed to secret scan on push", job?.data); - }); - - queueService.listen(QueueName.SecretFullRepoScan, "failed", (job, err) => { - logger.error(err, "Failed to do full repo secret scan", job?.data); - }); - return { startFullRepoScan, startPushEventScan }; }; 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 1c12f8b4e..a65bb9e68 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-service.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-service.ts @@ -98,6 +98,7 @@ export const secretScanningServiceFactory = ({ if (canUseSecretScanning(actorOrgId)) { await Promise.all( repositories.map(({ id, full_name }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access secretScanningQueue.startFullRepoScan({ organizationId: session.orgId, installationId, @@ -180,6 +181,7 @@ export const secretScanningServiceFactory = ({ if (!installationLink) return; if (canUseSecretScanning(installationLink.orgId)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access await secretScanningQueue.startPushEventScan({ commits, pusher: { name: pusher.name, email: pusher.email }, diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 1e641e813..0aa35b73d 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -73,6 +73,7 @@ type TWaitTillReady = { export type TKeyStoreFactory = { setItem: (key: string, value: string | number | Buffer, prefix?: string) => Promise<"OK">; getItem: (key: string, prefix?: string) => Promise; + getItems: (keys: string[], prefix?: string) => Promise<(string | null)[]>; setExpiry: (key: string, expiryInSeconds: number) => Promise; setItemWithExpiry: ( key: string, @@ -81,6 +82,7 @@ export type TKeyStoreFactory = { prefix?: string ) => Promise<"OK">; deleteItem: (key: string) => Promise; + deleteItemsByKeyIn: (keys: string[]) => Promise; deleteItems: (arg: TDeleteItems) => Promise; incrementBy: (key: string, value: number) => Promise; acquireLock( @@ -89,6 +91,7 @@ export type TKeyStoreFactory = { settings?: Partial ): Promise<{ release: () => Promise }>; waitTillReady: ({ key, waitingCb, keyCheckCb, waitIteration, delay, jitter }: TWaitTillReady) => Promise; + getKeysByPattern: (pattern: string, limit?: number) => Promise; }; export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => { @@ -100,6 +103,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); + const getItems = async (keys: string[], prefix?: string) => + redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key))); + const setItemWithExpiry = async ( key: string, expiryInSeconds: number | string, @@ -109,6 +115,11 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac const deleteItem = async (key: string) => redis.del(key); + const deleteItemsByKeyIn = async (keys: string[]) => { + if (keys.length === 0) return 0; + return redis.del(keys); + }; + const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => { let cursor = "0"; let totalDeleted = 0; @@ -164,6 +175,24 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac } }; + const getKeysByPattern = async (pattern: string, limit?: number) => { + let cursor = "0"; + const allKeys: string[] = []; + + do { + // eslint-disable-next-line no-await-in-loop + const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); + cursor = nextCursor; + allKeys.push(...keys); + + if (limit && allKeys.length >= limit) { + return allKeys.slice(0, limit); + } + } while (cursor !== "0"); + + return allKeys; + }; + return { setItem, getItem, @@ -175,6 +204,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac acquireLock(resources: string[], duration: number, settings?: Partial) { return redisLock.acquire(resources, duration, settings); }, - waitTillReady + waitTillReady, + getKeysByPattern, + deleteItemsByKeyIn, + getItems }; }; diff --git a/backend/src/keystore/memory.ts b/backend/src/keystore/memory.ts index 84cd06c03..cf9ba83bd 100644 --- a/backend/src/keystore/memory.ts +++ b/backend/src/keystore/memory.ts @@ -8,6 +8,8 @@ import { TKeyStoreFactory } from "./keystore"; export const inMemoryKeyStore = (): TKeyStoreFactory => { const store: Record = {}; + const getRegex = (pattern: string) => + new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); return { setItem: async (key, value) => { @@ -24,7 +26,7 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { return 1; }, deleteItems: async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }) => { - const regex = new RE2(`^${pattern.replace(/[-[\]/{}()+?.\\^$|]/g, "\\$&").replace(/\*/g, ".*")}$`); + const regex = getRegex(pattern); let totalDeleted = 0; const keys = Object.keys(store); @@ -59,6 +61,27 @@ export const inMemoryKeyStore = (): TKeyStoreFactory => { release: () => {} }) as Promise; }, - waitTillReady: async () => {} + waitTillReady: async () => {}, + getKeysByPattern: async (pattern) => { + const regex = getRegex(pattern); + const keys = Object.keys(store); + return keys.filter((key) => regex.test(key)); + }, + deleteItemsByKeyIn: async (keys) => { + for (const key of keys) { + delete store[key]; + } + return keys.length; + }, + getItems: async (keys) => { + const values = keys.map((key) => { + const value = store[key]; + if (typeof value === "string") { + return value; + } + return null; + }); + return values; + } }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index be460b4b4..7c7a6f654 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -22,6 +22,7 @@ export enum ApiDocsTags { UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", AliCloudAuth = "Alibaba Cloud Auth", + TlsCertAuth = "TLS Certificate Auth", AwsAuth = "AWS Auth", OciAuth = "OCI Auth", AzureAuth = "Azure Auth", @@ -283,6 +284,38 @@ export const ALICLOUD_AUTH = { } } as const; +export const TLS_CERT_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + allowedCommonNames: + "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", + caCertificate: "The PEM-encoded CA certificate to validate client certificates.", + accessTokenTTL: "The lifetime for an access token in seconds.", + accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + allowedCommonNames: + "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", + caCertificate: "The PEM-encoded CA certificate to validate client certificates.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the auth method for." + } +} as const; + export const AWS_AUTH = { LOGIN: { identityId: "The ID of the identity to login.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 99514556b..b9be95fea 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -193,6 +193,9 @@ const envSchema = z PYLON_API_KEY: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), + IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default( + "x-identity-tls-cert-auth-client-cert" + ), WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), WORKFLOW_SLACK_CLIENT_SECRET: zpStr(z.string().optional()), ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT: zodStrBool.default("true"), diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index b3be02c72..a8d0d10a6 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -62,7 +62,8 @@ export enum QueueName { SecretRotationV2 = "secret-rotation-v2", FolderTreeCheckpoint = "folder-tree-checkpoint", InvalidateCache = "invalidate-cache", - SecretScanningV2 = "secret-scanning-v2" + SecretScanningV2 = "secret-scanning-v2", + TelemetryAggregatedEvents = "telemetry-aggregated-events" } export enum QueueJobs { @@ -101,7 +102,8 @@ export enum QueueJobs { SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", - PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" + PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", + TelemetryAggregatedEvents = "telemetry-aggregated-events" } export type TQueueJobTypes = { @@ -292,6 +294,10 @@ export type TQueueJobTypes = { name: QueueJobs.PkiSubscriberDailyAutoRenewal; payload: undefined; }; + [QueueName.TelemetryAggregatedEvents]: { + name: QueueJobs.TelemetryAggregatedEvents; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 9b4c25716..401eeaf94 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -193,6 +193,8 @@ import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; +import { identityTlsCertAuthDALFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-dal"; +import { identityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-service"; import { identityTokenAuthDALFactory } from "@app/services/identity-token-auth/identity-token-auth-dal"; import { identityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; @@ -297,7 +299,6 @@ import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; -import { registerSecretScannerGhApp } from "../plugins/secret-scanner"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; @@ -326,7 +327,6 @@ export const registerRoutes = async ( } ) => { const appCfg = getConfig(); - await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); await server.register(registerSecretScanningV2Webhooks, { prefix: "/secret-scanning/webhooks" }); @@ -386,6 +386,7 @@ export const registerRoutes = async ( const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAliCloudAuthDAL = identityAliCloudAuthDALFactory(db); + const identityTlsCertAuthDAL = identityTlsCertAuthDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const identityOciAuthDAL = identityOciAuthDALFactory(db); @@ -686,7 +687,8 @@ export const registerRoutes = async ( const telemetryQueue = telemetryQueueServiceFactory({ keyStore, telemetryDAL, - queueService + queueService, + telemetryService }); const invalidateCacheQueue = invalidateCacheQueueFactory({ @@ -1417,7 +1419,8 @@ export const registerRoutes = async ( const identityAccessTokenService = identityAccessTokenServiceFactory({ identityAccessTokenDAL, identityOrgMembershipDAL, - accessTokenQueue + accessTokenQueue, + identityDAL }); const identityProjectService = identityProjectServiceFactory({ @@ -1493,6 +1496,15 @@ export const registerRoutes = async ( permissionService }); + const identityTlsCertAuthService = identityTlsCertAuthServiceFactory({ + identityAccessTokenDAL, + identityTlsCertAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService, + kmsService + }); + const identityAwsAuthService = identityAwsAuthServiceFactory({ identityAccessTokenDAL, identityAwsAuthDAL, @@ -1947,6 +1959,7 @@ export const registerRoutes = async ( identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, identityOciAuth: identityOciAuthService, + identityTlsCertAuth: identityTlsCertAuthService, identityOidcAuth: identityOidcAuthService, identityJwtAuth: identityJwtAuthService, identityLdapAuth: identityLdapAuthService, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index e35ac7d50..81e911621 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -761,6 +761,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.InvalidateCache, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { ...req.auditLogInfo diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 47594ac87..3d15b473a 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -692,6 +692,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueCert, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { caId: ca.id, @@ -786,6 +787,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SignCert, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { caId: ca.id, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 8194b9481..443d64e22 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -266,6 +266,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueCert, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { caId: req.body.caId, certificateTemplateId: req.body.certificateTemplateId, @@ -442,6 +443,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SignCert, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { caId: req.body.caId, certificateTemplateId: req.body.certificateTemplateId, diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 373e2d51f..dae18c23e 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -475,6 +475,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secretCountFromEnv, workspaceId: projectId, @@ -979,6 +980,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secretCount, workspaceId: projectId, @@ -1144,6 +1146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secretCountForEnv, workspaceId: projectId, @@ -1336,6 +1339,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: projectId, diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 2ea70af7a..c0578fc0a 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -85,6 +85,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.MachineIdentityCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { orgId: req.body.organizationId, name: identity.name, diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts new file mode 100644 index 000000000..40060ad40 --- /dev/null +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -0,0 +1,396 @@ +import crypto from "node:crypto"; + +import { z } from "zod"; + +import { IdentityTlsCertAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, TLS_CERT_AUTH } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; + +const validateCommonNames = z + .string() + .min(1) + .trim() + .transform((el) => + el + .split(",") + .map((i) => i.trim()) + .join(",") + ); + +const validateCaCertificate = (caCert: string) => { + if (!caCert) return true; + try { + // eslint-disable-next-line no-new + new crypto.X509Certificate(caCert); + return true; + } catch (err) { + return false; + } +}; + +export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.TlsCertAuth], + description: "Login with TLS Certificate Auth", + body: z.object({ + identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const appCfg = getConfig(); + const clientCertificate = req.headers[appCfg.IDENTITY_TLS_CERT_AUTH_CLIENT_CERTIFICATE_HEADER_KEY]; + if (!clientCertificate) { + throw new BadRequestError({ message: "Missing TLS certificate in header" }); + } + + const { identityTlsCertAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityTlsCertAuth.login({ + identityId: req.body.identityId, + clientCertificate: clientCertificate as string + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_TLS_CERT_AUTH, + metadata: { + identityId: identityTlsCertAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityTlsCertAuthId: identityTlsCertAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.TlsCertAuth], + description: "Attach TLS Certificate Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(TLS_CERT_AUTH.ATTACH.identityId) + }), + body: z + .object({ + allowedCommonNames: validateCommonNames + .optional() + .nullable() + .describe(TLS_CERT_AUTH.ATTACH.allowedCommonNames), + caCertificate: z + .string() + .min(1) + .max(10240) + .refine(validateCaCertificate, "Invalid CA Certificate.") + .describe(TLS_CERT_AUTH.ATTACH.caCertificate), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(TLS_CERT_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(TLS_CERT_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(TLS_CERT_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(TLS_CERT_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityTlsCertAuth: IdentityTlsCertAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTlsCertAuth = await server.services.identityTlsCertAuth.attachTlsCertAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ADD_IDENTITY_TLS_CERT_AUTH, + metadata: { + identityId: identityTlsCertAuth.identityId, + allowedCommonNames: identityTlsCertAuth.allowedCommonNames, + accessTokenTTL: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityTlsCertAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.TlsCertAuth], + description: "Update TLS Certificate Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(TLS_CERT_AUTH.UPDATE.identityId) + }), + body: z + .object({ + caCertificate: z + .string() + .min(1) + .max(10240) + .refine(validateCaCertificate, "Invalid CA Certificate.") + .optional() + .describe(TLS_CERT_AUTH.UPDATE.caCertificate), + allowedCommonNames: validateCommonNames + .optional() + .nullable() + .describe(TLS_CERT_AUTH.UPDATE.allowedCommonNames), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional() + .describe(TLS_CERT_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .optional() + .describe(TLS_CERT_AUTH.UPDATE.accessTokenTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .optional() + .describe(TLS_CERT_AUTH.UPDATE.accessTokenNumUsesLimit), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .min(0) + .optional() + .describe(TLS_CERT_AUTH.UPDATE.accessTokenMaxTTL) + }) + .refine( + (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityTlsCertAuth: IdentityTlsCertAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTlsCertAuth = await server.services.identityTlsCertAuth.updateTlsCertAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_IDENTITY_TLS_CERT_AUTH, + metadata: { + identityId: identityTlsCertAuth.identityId, + allowedCommonNames: identityTlsCertAuth.allowedCommonNames, + accessTokenTTL: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityTlsCertAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityTlsCertAuth }; + } + }); + + server.route({ + method: "GET", + url: "/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.TlsCertAuth], + description: "Retrieve TLS Certificate Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(TLS_CERT_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityTlsCertAuth: IdentityTlsCertAuthsSchema.extend({ + caCertificate: z.string() + }) + }) + } + }, + handler: async (req) => { + const identityTlsCertAuth = await server.services.identityTlsCertAuth.getTlsCertAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_IDENTITY_TLS_CERT_AUTH, + metadata: { + identityId: identityTlsCertAuth.identityId + } + } + }); + return { identityTlsCertAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.TlsCertAuth], + description: "Delete TLS Certificate Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(TLS_CERT_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityTlsCertAuth: IdentityTlsCertAuthsSchema + }) + } + }, + handler: async (req) => { + const identityTlsCertAuth = await server.services.identityTlsCertAuth.revokeTlsCertAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.REVOKE_IDENTITY_TLS_CERT_AUTH, + metadata: { + identityId: identityTlsCertAuth.identityId + } + } + }); + + return { identityTlsCertAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 2363147b6..e6aa2e83f 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -25,6 +25,7 @@ import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; +import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; @@ -66,6 +67,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAliCloudAuthRouter); await authRouter.register(registerIdentityAwsAuthRouter); + await authRouter.register(registerIdentityTlsCertAuthRouter, { prefix: "/tls-cert-auth" }); await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOciAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f3964e7b7..95477c341 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -103,6 +103,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IntegrationCreated, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { ...createIntegrationEventProperty, diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index b688dc042..b98e94be0 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -64,6 +64,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.UserOrgInvitation, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { inviteeEmails: req.body.inviteeEmails, organizationRoleSlug: req.body.organizationRoleSlug, diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 761904fd1..0e9ec6e0c 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -331,6 +331,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueCert, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { subscriberId: subscriber.id, @@ -399,6 +400,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.IssueCert, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { subscriberId: subscriber.id, commonName: subscriber.commonName, @@ -471,6 +473,7 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SignCert, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { subscriberId: subscriber.id, commonName: subscriber.commonName, diff --git a/backend/src/server/routes/v1/secret-requests-router.ts b/backend/src/server/routes/v1/secret-requests-router.ts index a1e4eafc2..1bd044d64 100644 --- a/backend/src/server/routes/v1/secret-requests-router.ts +++ b/backend/src/server/routes/v1/secret-requests-router.ts @@ -165,6 +165,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) = await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretRequestDeleted, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { secretRequestId: req.params.id, organizationId: req.permission.orgId, @@ -256,6 +257,7 @@ export const registerSecretRequestsRouter = async (server: FastifyZodProvider) = await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretRequestCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { secretRequestId: shareRequest.id, organizationId: req.permission.orgId, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index d14a75ded..e3ffdd282 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -198,6 +198,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.ProjectCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { orgId: project.orgId, name: project.name, diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 33878f1e7..8784989bd 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -333,6 +333,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId, @@ -489,6 +490,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, + organizationId: req.permission.orgId, distinctId: getTelemetryDistinctId(req), properties: { numberOfSecrets: 1, @@ -615,6 +617,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: projectId, @@ -750,6 +753,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: projectId, @@ -850,6 +854,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: projectId, @@ -957,6 +962,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: req.query.workspaceId, @@ -1036,6 +1042,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: req.query.workspaceId, @@ -1207,6 +1214,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -1396,6 +1404,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -1519,6 +1528,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: 1, workspaceId: req.body.workspaceId, @@ -1702,6 +1712,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -1828,6 +1839,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -1946,6 +1958,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: req.body.workspaceId, @@ -2054,6 +2067,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretCreated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: secrets[0].workspace, @@ -2209,6 +2223,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretUpdated, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: secrets[0].workspace, @@ -2307,6 +2322,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretDeleted, distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, properties: { numberOfSecrets: secrets.length, workspaceId: secrets[0].workspace, diff --git a/backend/src/services/app-connection/gcp/gcp-connection-service.ts b/backend/src/services/app-connection/gcp/gcp-connection-service.ts index 74f2ab2c4..4f2337302 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-service.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-service.ts @@ -1,3 +1,4 @@ +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; @@ -19,6 +20,7 @@ export const gcpConnectionService = (getAppConnection: TGetAppConnectionFunc) => return projects; } catch (error) { + logger.error(error, "Error listing GCP secret manager projects"); return []; } }; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 879ca9fd3..8f65d8555 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -17,70 +17,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { const doc = await (tx || db.replicaNode())(TableName.IdentityAccessToken) .where(filter) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) - .leftJoin( - TableName.IdentityUaClientSecret, - `${TableName.IdentityAccessToken}.identityUAClientSecretId`, - `${TableName.IdentityUaClientSecret}.id` - ) - .leftJoin( - TableName.IdentityUniversalAuth, - `${TableName.IdentityUaClientSecret}.identityUAId`, - `${TableName.IdentityUniversalAuth}.id` - ) - .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) - .leftJoin( - TableName.IdentityAliCloudAuth, - `${TableName.Identity}.id`, - `${TableName.IdentityAliCloudAuth}.identityId` - ) - .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) - .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) - .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) - .leftJoin( - TableName.IdentityKubernetesAuth, - `${TableName.Identity}.id`, - `${TableName.IdentityKubernetesAuth}.identityId` - ) - .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) - .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) - .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) - .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) - .select( - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), - db - .ref("accessTokenTrustedIps") - .withSchema(TableName.IdentityAliCloudAuth) - .as("accessTokenTrustedIpsAliCloud"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOciAuth).as("accessTokenTrustedIpsOci"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityOidcAuth).as("accessTokenTrustedIpsOidc"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityTokenAuth).as("accessTokenTrustedIpsToken"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityJwtAuth).as("accessTokenTrustedIpsJwt"), - db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityLdapAuth).as("accessTokenTrustedIpsLdap"), - db.ref("name").withSchema(TableName.Identity) - ) + .select(db.ref("name").withSchema(TableName.Identity)) .first(); - if (!doc) return; - - return { - ...doc, - trustedIpsUniversalAuth: doc.accessTokenTrustedIpsUa, - trustedIpsGcpAuth: doc.accessTokenTrustedIpsGcp, - trustedIpsAliCloudAuth: doc.accessTokenTrustedIpsAliCloud, - trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws, - trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure, - trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s, - trustedIpsOciAuth: doc.accessTokenTrustedIpsOci, - trustedIpsOidcAuth: doc.accessTokenTrustedIpsOidc, - trustedIpsAccessTokenAuth: doc.accessTokenTrustedIpsToken, - trustedIpsAccessJwtAuth: doc.accessTokenTrustedIpsJwt, - trustedIpsAccessLdapAuth: doc.accessTokenTrustedIpsLdap - }; + return doc; } catch (error) { throw new DatabaseError({ error, name: "IdAccessTokenFindOne" }); } diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 7c8944f50..0937b9640 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -7,12 +7,14 @@ import { checkIPAgainstBlocklist, TIp } from "@app/lib/ip"; import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue"; import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "./identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload, TRenewAccessTokenDTO } from "./identity-access-token-types"; type TIdentityAccessTokenServiceFactoryDep = { identityAccessTokenDAL: TIdentityAccessTokenDALFactory; + identityDAL: Pick; identityOrgMembershipDAL: TIdentityOrgDALFactory; accessTokenQueue: Pick< TAccessTokenQueueServiceFactory, @@ -25,7 +27,8 @@ export type TIdentityAccessTokenServiceFactory = ReturnType { const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { @@ -190,23 +193,11 @@ export const identityAccessTokenServiceFactory = ({ message: "Failed to authorize revoked access token, access token is revoked" }); - const trustedIpsMap: Record = { - [IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth, - [IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth, - [IdentityAuthMethod.ALICLOUD_AUTH]: identityAccessToken.trustedIpsAliCloudAuth, - [IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth, - [IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth, - [IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth, - [IdentityAuthMethod.KUBERNETES_AUTH]: identityAccessToken.trustedIpsKubernetesAuth, - [IdentityAuthMethod.OIDC_AUTH]: identityAccessToken.trustedIpsOidcAuth, - [IdentityAuthMethod.TOKEN_AUTH]: identityAccessToken.trustedIpsAccessTokenAuth, - [IdentityAuthMethod.JWT_AUTH]: identityAccessToken.trustedIpsAccessJwtAuth, - [IdentityAuthMethod.LDAP_AUTH]: identityAccessToken.trustedIpsAccessLdapAuth - }; - - const trustedIps = trustedIpsMap[identityAccessToken.authMethod as IdentityAuthMethod]; - - if (ipAddress) { + const trustedIps = await identityDAL.getTrustedIpsByAuthMethod( + identityAccessToken.identityId, + identityAccessToken.authMethod as IdentityAuthMethod + ); + if (ipAddress && trustedIps) { checkIPAgainstBlocklist({ ipAddress, trustedIps: trustedIps as TIp[] diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts new file mode 100644 index 000000000..951077f33 --- /dev/null +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, TOrmify } from "@app/lib/knex"; + +export type TIdentityTlsCertAuthDALFactory = TOrmify; + +export const identityTlsCertAuthDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityTlsCertAuth); + return orm; +}; diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts new file mode 100644 index 000000000..11dd312ad --- /dev/null +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -0,0 +1,423 @@ +import crypto from "node:crypto"; + +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; +import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; + +type TIdentityTlsCertAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityTlsCertAuthDAL: Pick< + TIdentityTlsCertAuthDALFactory, + "findOne" | "transaction" | "create" | "updateById" | "delete" + >; + identityOrgMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; + kmsService: Pick; +}; + +const parseSubjectDetails = (data: string) => { + const values: Record = {}; + data.split("\n").forEach((el) => { + const [key, value] = el.split("="); + values[key.trim()] = value.trim(); + }); + return values; +}; + +export const identityTlsCertAuthServiceFactory = ({ + identityAccessTokenDAL, + identityTlsCertAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService, + kmsService +}: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { + const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { + const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); + if (!identityTlsCertAuth) { + throw new NotFoundError({ + message: "TLS Certificate auth method not found for identity, did you configure TLS Certificate auth?" + }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityTlsCertAuth.identityId + }); + + if (!identityMembershipOrg) { + throw new NotFoundError({ + message: `Identity organization membership for identity with ID '${identityTlsCertAuth.identityId}' not found` + }); + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const caCertificate = decryptor({ + cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate + }).toString(); + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; + if (!leafCertificate) { + throw new BadRequestError({ message: "Missing client certificate" }); + } + + const clientCertificateX509 = new crypto.X509Certificate(leafCertificate); + const caCertificateX509 = new crypto.X509Certificate(caCertificate); + + const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); + if (!isValidCertificate) + throw new UnauthorizedError({ + message: "Access denied: Certificate not issued by the provided CA." + }); + + if (new Date(clientCertificateX509.validTo) < new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate has expired." + }); + } + + if (new Date(clientCertificateX509.validFrom) > new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate not yet valid." + }); + } + + const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); + if (identityTlsCertAuth.allowedCommonNames) { + const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN); + if (!isValidCommonName) { + throw new UnauthorizedError({ + message: "Access denied: TLS Certificate Auth common name not allowed." + }); + } + } + + // Generate the token + const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityTlsCertAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.TLS_CERT_AUTH + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityTlsCertAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + return { + identityTlsCertAuth, + accessToken, + identityAccessToken, + identityMembershipOrg + }; + }; + + const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({ + identityId, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin, + caCertificate, + allowedCommonNames + }) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { + throw new BadRequestError({ + message: "Failed to add TLS Certificate Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const identityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => { + const doc = await identityTlsCertAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + accessTokenMaxTTL, + allowedCommonNames, + accessTokenTTL, + encryptedCaCertificate: encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + return doc; + }); + return { ...identityTlsCertAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateTlsCertAuth: TIdentityTlsCertAuthServiceFactory["updateTlsCertAuth"] = async ({ + identityId, + caCertificate, + allowedCommonNames, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { + throw new NotFoundError({ + message: "The identity does not have TLS Certificate Auth attached" + }); + } + + const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityTlsCertAuth.accessTokenTTL) > + (accessTokenMaxTTL || identityTlsCertAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const updatedTlsCertAuth = await identityTlsCertAuthDAL.updateById(identityTlsCertAuth.id, { + allowedCommonNames, + encryptedCaCertificate: caCertificate + ? encryptor({ plainText: Buffer.from(caCertificate) }).cipherTextBlob + : undefined, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedTlsCertAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getTlsCertAuth: TIdentityTlsCertAuthServiceFactory["getTlsCertAuth"] = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have TLS Certificate Auth attached" + }); + } + + const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + let caCertificate = ""; + if (identityAuth.encryptedCaCertificate) { + caCertificate = decryptor({ cipherTextBlob: identityAuth.encryptedCaCertificate }).toString(); + } + + return { ...identityAuth, caCertificate, orgId: identityMembershipOrg.orgId }; + }; + + const revokeTlsCertAuth: TIdentityTlsCertAuthServiceFactory["revokeTlsCertAuth"] = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have TLS Certificate auth" + }); + } + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke TLS Certificate auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const revokedIdentityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => { + const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx); + + return { ...deletedTlsCertAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityTlsCertAuth; + }; + + return { + login, + attachTlsCertAuth, + updateTlsCertAuth, + getTlsCertAuth, + revokeTlsCertAuth + }; +}; diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts new file mode 100644 index 000000000..b7a08276b --- /dev/null +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts @@ -0,0 +1,49 @@ +import { TIdentityAccessTokens, TIdentityOrgMemberships, TIdentityTlsCertAuths } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginTlsCertAuthDTO = { + identityId: string; + clientCertificate: string; +}; + +export type TAttachTlsCertAuthDTO = { + identityId: string; + caCertificate: string; + allowedCommonNames?: string | null; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateTlsCertAuthDTO = { + identityId: string; + caCertificate?: string; + allowedCommonNames?: string | null; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetTlsCertAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeTlsCertAuthDTO = { + identityId: string; +} & Omit; + +export type TIdentityTlsCertAuthServiceFactory = { + login: (dto: TLoginTlsCertAuthDTO) => Promise<{ + identityTlsCertAuth: TIdentityTlsCertAuths; + accessToken: string; + identityAccessToken: TIdentityAccessTokens; + identityMembershipOrg: TIdentityOrgMemberships; + }>; + attachTlsCertAuth: (dto: TAttachTlsCertAuthDTO) => Promise; + updateTlsCertAuth: (dto: TUpdateTlsCertAuthDTO) => Promise; + revokeTlsCertAuth: (dto: TRevokeTlsCertAuthDTO) => Promise; + getTlsCertAuth: (dto: TGetTlsCertAuthDTO) => Promise; +}; diff --git a/backend/src/services/identity/identity-dal.ts b/backend/src/services/identity/identity-dal.ts index c4d0b6307..363412493 100644 --- a/backend/src/services/identity/identity-dal.ts +++ b/backend/src/services/identity/identity-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { TableName, TIdentities } from "@app/db/schemas"; +import { IdentityAuthMethod, TableName, TIdentities } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; @@ -8,6 +8,28 @@ export type TIdentityDALFactory = ReturnType; export const identityDALFactory = (db: TDbClient) => { const identityOrm = ormify(db, TableName.Identity); + const getTrustedIpsByAuthMethod = async (identityId: string, authMethod: IdentityAuthMethod) => { + const authMethodToTableName = { + [IdentityAuthMethod.TOKEN_AUTH]: TableName.IdentityTokenAuth, + [IdentityAuthMethod.UNIVERSAL_AUTH]: TableName.IdentityUniversalAuth, + [IdentityAuthMethod.KUBERNETES_AUTH]: TableName.IdentityKubernetesAuth, + [IdentityAuthMethod.GCP_AUTH]: TableName.IdentityGcpAuth, + [IdentityAuthMethod.ALICLOUD_AUTH]: TableName.IdentityAliCloudAuth, + [IdentityAuthMethod.AWS_AUTH]: TableName.IdentityAwsAuth, + [IdentityAuthMethod.AZURE_AUTH]: TableName.IdentityAzureAuth, + [IdentityAuthMethod.TLS_CERT_AUTH]: TableName.IdentityTlsCertAuth, + [IdentityAuthMethod.OCI_AUTH]: TableName.IdentityOciAuth, + [IdentityAuthMethod.OIDC_AUTH]: TableName.IdentityOidcAuth, + [IdentityAuthMethod.JWT_AUTH]: TableName.IdentityJwtAuth, + [IdentityAuthMethod.LDAP_AUTH]: TableName.IdentityLdapAuth + } as const; + const tableName = authMethodToTableName[authMethod]; + if (!tableName) return; + const data = await db(tableName).where({ identityId }).first(); + if (!data) return; + return data.accessTokenTrustedIps; + }; + const getIdentitiesByFilter = async ({ limit, offset, @@ -38,5 +60,5 @@ export const identityDALFactory = (db: TDbClient) => { } }; - return { ...identityOrm, getIdentitiesByFilter }; + return { ...identityOrm, getTrustedIpsByAuthMethod, getIdentitiesByFilter }; }; diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 3020d9c47..dee87fc49 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -11,7 +11,8 @@ export const buildAuthMethods = ({ azureId, tokenId, jwtId, - ldapId + ldapId, + tlsCertId }: { uaId?: string; gcpId?: string; @@ -24,6 +25,7 @@ export const buildAuthMethods = ({ tokenId?: string; jwtId?: string; ldapId?: string; + tlsCertId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -36,6 +38,7 @@ export const buildAuthMethods = ({ ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null], - ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null] + ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null], + ...[tlsCertId ? IdentityAuthMethod.TLS_CERT_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 28064c9bb..5ca6cbbc1 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -12,6 +12,7 @@ import { TIdentityOciAuths, TIdentityOidcAuths, TIdentityOrgMemberships, + TIdentityTlsCertAuths, TIdentityTokenAuths, TIdentityUniversalAuths, TOrgRoles @@ -99,7 +100,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityLdapAuth}.identityId` ) - + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -114,6 +119,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth), db.ref("name").withSchema(TableName.Identity), db.ref("hasDeleteProtection").withSchema(TableName.Identity) ); @@ -238,7 +244,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityLdapAuth}.identityId` ) - + .leftJoin( + TableName.IdentityTlsCertAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityTlsCertAuth}.identityId` + ) .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -260,7 +270,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), - db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -306,6 +317,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { azureId, tokenId, ldapId, + tlsCertId, createdAt, updatedAt }) => ({ @@ -313,7 +325,6 @@ export const identityOrgDALFactory = (db: TDbClient) => { roleId, identityId, id, - orgId, createdAt, updatedAt, @@ -341,7 +352,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { azureId, tokenId, jwtId, - ldapId + ldapId, + tlsCertId }) } }), diff --git a/backend/src/services/telemetry/telemetry-queue.ts b/backend/src/services/telemetry/telemetry-queue.ts index c3e5471b0..994ebbd38 100644 --- a/backend/src/services/telemetry/telemetry-queue.ts +++ b/backend/src/services/telemetry/telemetry-queue.ts @@ -7,13 +7,18 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { getServerCfg } from "../super-admin/super-admin-service"; import { TTelemetryDALFactory } from "./telemetry-dal"; -import { TELEMETRY_SECRET_OPERATIONS_KEY, TELEMETRY_SECRET_PROCESSED_KEY } from "./telemetry-service"; +import { + TELEMETRY_SECRET_OPERATIONS_KEY, + TELEMETRY_SECRET_PROCESSED_KEY, + TTelemetryServiceFactory +} from "./telemetry-service"; import { PostHogEventTypes } from "./telemetry-types"; type TTelemetryQueueServiceFactoryDep = { queueService: TQueueServiceFactory; keyStore: Pick; telemetryDAL: TTelemetryDALFactory; + telemetryService: TTelemetryServiceFactory; }; export type TTelemetryQueueServiceFactory = ReturnType; @@ -21,7 +26,8 @@ export type TTelemetryQueueServiceFactory = ReturnType { const appCfg = getConfig(); const postHog = @@ -48,6 +54,10 @@ export const telemetryQueueServiceFactory = ({ await keyStore.deleteItem(TELEMETRY_SECRET_OPERATIONS_KEY); }); + queueService.start(QueueName.TelemetryAggregatedEvents, async () => { + await telemetryService.processAggregatedEvents(); + }); + // every day at midnight a telemetry job executes on self-hosted instances // this sends some telemetry information like instance id secrets operated etc const startTelemetryCheck = async () => { @@ -60,11 +70,26 @@ export const telemetryQueueServiceFactory = ({ { pattern: "0 0 * * *", utc: true }, QueueName.TelemetryInstanceStats // just a job id ); + + // clear previous aggregated events job + await queueService.stopRepeatableJob( + QueueName.TelemetryAggregatedEvents, + QueueJobs.TelemetryAggregatedEvents, + { pattern: "*/5 * * * *", utc: true }, + QueueName.TelemetryAggregatedEvents // just a job id + ); + if (postHog) { await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, { jobId: QueueName.TelemetryInstanceStats, repeat: { pattern: "0 0 * * *", utc: true } }); + + // Start aggregated events job (runs every five minutes) + await queueService.queue(QueueName.TelemetryAggregatedEvents, QueueJobs.TelemetryAggregatedEvents, undefined, { + jobId: QueueName.TelemetryAggregatedEvents, + repeat: { pattern: "*/5 * * * *", utc: true } + }); } }; @@ -72,6 +97,10 @@ export const telemetryQueueServiceFactory = ({ logger.error(err?.failedReason, `${QueueName.TelemetryInstanceStats}: failed`); }); + queueService.listen(QueueName.TelemetryAggregatedEvents, "failed", (err) => { + logger.error(err?.failedReason, `${QueueName.TelemetryAggregatedEvents}: failed`); + }); + return { startTelemetryCheck }; diff --git a/backend/src/services/telemetry/telemetry-service.ts b/backend/src/services/telemetry/telemetry-service.ts index e02cff8a4..eaab5bec6 100644 --- a/backend/src/services/telemetry/telemetry-service.ts +++ b/backend/src/services/telemetry/telemetry-service.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from "crypto"; import { PostHog } from "posthog-node"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -12,12 +13,49 @@ import { PostHogEventTypes, TPostHogEvent, TSecretModifiedEvent } from "./teleme export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed"; export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations"; +export const POSTHOG_AGGREGATED_EVENTS = [PostHogEventTypes.SecretPulled]; +const TELEMETRY_AGGREGATED_KEY_EXP = 900; // 15mins + +// Bucket configuration +const TELEMETRY_BUCKET_COUNT = 30; +const TELEMETRY_BUCKET_NAMES = Array.from( + { length: TELEMETRY_BUCKET_COUNT }, + (_, i) => `bucket-${i.toString().padStart(2, "0")}` +); + +type AggregatedEventData = Record; +type SingleEventData = { + distinctId: string; + event: string; + properties: unknown; + organizationId: string; +}; + export type TTelemetryServiceFactory = ReturnType; export type TTelemetryServiceFactoryDep = { - keyStore: Pick; + keyStore: Pick< + TKeyStoreFactory, + "incrementBy" | "deleteItemsByKeyIn" | "setItemWithExpiry" | "getKeysByPattern" | "getItems" + >; licenseService: Pick; }; +const getBucketForDistinctId = (distinctId: string): string => { + // Use SHA-256 hash for consistent distribution + const hash = createHash("sha256").update(distinctId).digest("hex"); + + // Take first 8 characters and convert to number for better distribution + const hashNumber = parseInt(hash.substring(0, 8), 16); + const bucketIndex = hashNumber % TELEMETRY_BUCKET_COUNT; + + return TELEMETRY_BUCKET_NAMES[bucketIndex]; +}; + +export const createTelemetryEventKey = (event: string, distinctId: string): string => { + const bucketId = getBucketForDistinctId(distinctId); + return `telemetry-event-${event}-${bucketId}-${distinctId}-${randomUUID()}`; +}; + export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => { const appCfg = getConfig(); @@ -64,11 +102,33 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme const instanceType = licenseService.getInstanceType(); // capture posthog only when its cloud or signup event happens in self-hosted if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) { - postHog.capture({ - event: event.event, - distinctId: event.distinctId, - properties: event.properties - }); + if (event.organizationId) { + try { + postHog.groupIdentify({ groupType: "organization", groupKey: event.organizationId }); + } catch (error) { + logger.error(error, "Failed to identify PostHog organization"); + } + } + if (POSTHOG_AGGREGATED_EVENTS.includes(event.event)) { + const eventKey = createTelemetryEventKey(event.event, event.distinctId); + await keyStore.setItemWithExpiry( + eventKey, + TELEMETRY_AGGREGATED_KEY_EXP, + JSON.stringify({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties, + organizationId: event.organizationId + }) + ); + } else { + postHog.capture({ + event: event.event, + distinctId: event.distinctId, + properties: event.properties, + ...(event.organizationId ? { groups: { organization: event.organizationId } } : {}) + }); + } return; } @@ -89,6 +149,160 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme } }; + const aggregateGroupProperties = (events: SingleEventData[]): AggregatedEventData => { + const aggregatedData: AggregatedEventData = {}; + + // Set the total count + aggregatedData.count = events.length; + + events.forEach((event) => { + if (!event.properties) return; + + Object.entries(event.properties as Record).forEach(([key, value]: [string, unknown]) => { + if (Array.isArray(value)) { + // For arrays, count occurrences of each item + const existingCounts = + aggregatedData[key] && + typeof aggregatedData[key] === "object" && + aggregatedData[key]?.constructor === Object + ? (aggregatedData[key] as Record) + : {}; + + value.forEach((item) => { + const itemKey = typeof item === "object" ? JSON.stringify(item) : String(item); + existingCounts[itemKey] = (existingCounts[itemKey] || 0) + 1; + }); + + aggregatedData[key] = existingCounts; + } else if (typeof value === "object" && value?.constructor === Object) { + // For objects, count occurrences of each field value + const existingCounts = + aggregatedData[key] && + typeof aggregatedData[key] === "object" && + aggregatedData[key]?.constructor === Object + ? (aggregatedData[key] as Record) + : {}; + + if (value) { + Object.values(value).forEach((fieldValue) => { + const valueKey = typeof fieldValue === "object" ? JSON.stringify(fieldValue) : String(fieldValue); + existingCounts[valueKey] = (existingCounts[valueKey] || 0) + 1; + }); + } + aggregatedData[key] = existingCounts; + } else if (typeof value === "number") { + // For numbers, add to existing sum + aggregatedData[key] = ((aggregatedData[key] as number) || 0) + value; + } else if (value !== undefined && value !== null) { + // For other types (strings, booleans, etc.), count occurrences + const stringValue = String(value); + const existingValue = aggregatedData[key]; + + if (!existingValue) { + aggregatedData[key] = { [stringValue]: 1 }; + } else if (existingValue && typeof existingValue === "object" && existingValue.constructor === Object) { + const countObject = existingValue as Record; + countObject[stringValue] = (countObject[stringValue] || 0) + 1; + } else { + const oldValue = String(existingValue); + aggregatedData[key] = { + [oldValue]: 1, + [stringValue]: 1 + }; + } + } + }); + }); + + return aggregatedData; + }; + + const processBucketEvents = async (eventType: string, bucketId: string) => { + if (!postHog) return 0; + + try { + const bucketPattern = `telemetry-event-${eventType}-${bucketId}-*`; + const bucketKeys = await keyStore.getKeysByPattern(bucketPattern); + + if (bucketKeys.length === 0) return 0; + + const bucketEvents = await keyStore.getItems(bucketKeys); + let bucketEventsParsed: SingleEventData[] = []; + + try { + bucketEventsParsed = bucketEvents + .filter((event) => event !== null) + .map((event) => JSON.parse(event as string) as SingleEventData); + } catch (error) { + logger.error(error, `Failed to parse bucket events for ${eventType} in ${bucketId}`); + return 0; + } + + const eventsGrouped = new Map(); + + bucketEventsParsed.forEach((event) => { + const key = JSON.stringify({ id: event.distinctId, org: event.organizationId }); + if (!eventsGrouped.has(key)) { + eventsGrouped.set(key, []); + } + eventsGrouped.get(key)!.push(event); + }); + + if (eventsGrouped.size === 0) return 0; + + for (const [eventsKey, events] of eventsGrouped) { + const key = JSON.parse(eventsKey) as { id: string; org?: string }; + if (key.org) { + try { + postHog.groupIdentify({ groupType: "organization", groupKey: key.org }); + } catch (error) { + logger.error(error, "Failed to identify PostHog organization"); + } + } + const properties = aggregateGroupProperties(events); + + postHog.capture({ + event: `${eventType} aggregated`, + distinctId: key.id, + properties, + ...(key.org ? { groups: { organization: key.org } } : {}) + }); + } + + // Clean up processed data for this bucket + await keyStore.deleteItemsByKeyIn(bucketKeys); + + logger.info(`Processed ${bucketEventsParsed.length} events from bucket ${bucketId} for ${eventType}`); + return bucketEventsParsed.length; + } catch (error) { + logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`); + return 0; + } + }; + + const processAggregatedEvents = async () => { + if (!postHog) return; + + for (const eventType of POSTHOG_AGGREGATED_EVENTS) { + let totalProcessed = 0; + + logger.info(`Starting bucket processing for ${eventType}`); + + // Process each bucket sequentially to control memory usage + for (const bucketId of TELEMETRY_BUCKET_NAMES) { + try { + // eslint-disable-next-line no-await-in-loop + const processed = await processBucketEvents(eventType, bucketId); + totalProcessed += processed; + } catch (error) { + logger.error(error, `Failed to process bucket ${bucketId} for ${eventType}`); + } + } + + logger.info(`Completed processing ${totalProcessed} total events for ${eventType}`); + } + }; + const flushAll = async () => { if (postHog) { await postHog.shutdownAsync(); @@ -98,6 +312,8 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme return { sendLoopsEvent, sendPostHogEvents, - flushAll + processAggregatedEvents, + flushAll, + getBucketForDistinctId }; }; diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index ab8fc51c5..7e2027f25 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -1,3 +1,13 @@ +import { + IdentityActor, + KmipClientActor, + PlatformActor, + ScimClientActor, + ServiceActor, + UnknownUserActor, + UserActor +} from "@app/ee/services/audit-log/audit-log-types"; + export enum PostHogEventTypes { SecretPush = "secrets pushed", SecretPulled = "secrets pulled", @@ -40,6 +50,14 @@ export type TSecretModifiedEvent = { secretPath: string; channel?: string; userAgent?: string; + actor?: + | UserActor + | IdentityActor + | ServiceActor + | ScimClientActor + | PlatformActor + | UnknownUserActor + | KmipClientActor; }; }; @@ -214,7 +232,7 @@ export type TInvalidateCacheEvent = { }; }; -export type TPostHogEvent = { distinctId: string } & ( +export type TPostHogEvent = { distinctId: string; organizationId?: string } & ( | TSecretModifiedEvent | TAdminInitEvent | TUserSignedUpEvent diff --git a/company/handbook/spending-money.mdx b/company/handbook/spending-money.mdx index 864984f10..e3a92b2f4 100644 --- a/company/handbook/spending-money.mdx +++ b/company/handbook/spending-money.mdx @@ -11,9 +11,9 @@ Fairly frequently, you might run into situations when you need to spend company As a perk of working at Infisical, we cover some of your meal expenses. -HQ team members: meals and unlimited snacks are provided on-site at no cost. +**HQ team members**: meals and unlimited snacks are provided **on-site** at no cost. -Remote team members: a food stipend is allocated based on location. +**Remote team members**: a food stipend is allocated based on location. # Trivial expenses @@ -27,21 +27,28 @@ This means expenses that are: Please spend money in a way that you think is in the best interest of the company. -## Saving receipts -Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. +# Travel -You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually. +If you need to travel on Infisical’s behalf for in-person onboarding, meeting customers, and offsites, again please spend money in the best interests of the company. -## Training +We do not pre-approve your travel expenses, and trust team members to make the right decisions here. Some guidance: + +- Please find a flight ticket that is reasonably priced. We all travel economy by default – we cannot afford for folks to fly premium or business class. Feel free to upgrade using your personal money/airmiles if you’d like to. +- Feel free to pay for the Uber/subway/bus to and from the airport with your Brex card. +- For business travel, Infisical will cover reasonable expenses for breakfast, lunch, and dinner. +- When traveling internationally, Infisical does not cover roaming charges for your phone. You can expense a reasonable eSIM, which usually is no more than $20. + + +Note that this only applies to business travel. It is not applicable for personal travel or day-to-day commuting. + -For engineers, you’re welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if it’s relevant to your work. # Equipment Infisical is a remote first company so we understand the importance of having a comfortable work setup. To support this, we provide allowances for essential office equipment. -### Desk & Chair +### 1. Desk & Chair Most people already have a comfortable desk and chair, but if you need an upgrade, we offer the following allowances. While we're not yet able to provide the latest and greatest, we strive to be reasonable given the stage of our company. @@ -50,10 +57,10 @@ While we're not yet able to provide the latest and greatest, we strive to be rea **Chair**: $150 USD -### Laptop +### 2. Laptop Each team member will receive a company-issued Macbook Pro before they start their first day. -### Notes +### 3. Notes 1. All equipment purchased using company allowances remains the property of Infisical. 2. Keep all receipts for equipment purchases and submit them for reimbursement. @@ -65,6 +72,28 @@ This is because we don't yet have a formal HR department to handle such logistic For any equipment related questions, please reach out to Maidul. -## Brex +# Brex We use Brex as our primary credit card provider. Don't have a company card yet? Reach out to Maidul. + +### Budgets + +You will generally have multiple budgets assigned to you. "General Company Expenses" primarily covers quick SaaS purchases (not food). Remote team members should have a "Lunch Stipend" budget that applies to food. + +If your position involves a lot of travel, you may also have a "Travel" budget that applies to expenses related to business travel (e.g., you can not use it for transportation or food during personal travel). + +### Saving receipts + +Make sure you keep copies for all receipts. If you expense something on a company card and cannot provide a receipt, this may be deducted from your pay. + +You should default to using your company card in all cases - it has no transaction fees. If using your personal card is unavoidable, please reach out to Maidul to get it reimbursed manually. + +### ​Need a one-off budget increase? + +You can do this directly within Brex - just request the amount and duration for the relevant budget in the app, and your hiring manager will automatically be notified for approval. + + +# Training + +For engineers, you’re welcome to take an approved Udemy course. Please reach out to Maidul. For the GTM team, you may buy a book a month if it’s relevant to your work. + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 590e17763..209c6e62e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,7 +4,7 @@ services: nginx: container_name: infisical-dev-nginx image: nginx - restart: always + restart: "always" ports: - 8080:80 - 8443:443 diff --git a/docs/api-reference/endpoints/tls-cert-auth/attach.mdx b/docs/api-reference/endpoints/tls-cert-auth/attach.mdx new file mode 100644 index 000000000..35c3b87e9 --- /dev/null +++ b/docs/api-reference/endpoints/tls-cert-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/tls-cert-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/tls-cert-auth/login.mdx b/docs/api-reference/endpoints/tls-cert-auth/login.mdx new file mode 100644 index 000000000..0069ef1b7 --- /dev/null +++ b/docs/api-reference/endpoints/tls-cert-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/tls-cert-auth/login" +--- diff --git a/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx b/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx new file mode 100644 index 000000000..d59b31d11 --- /dev/null +++ b/docs/api-reference/endpoints/tls-cert-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/tls-cert-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx b/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx new file mode 100644 index 000000000..0d3ccda65 --- /dev/null +++ b/docs/api-reference/endpoints/tls-cert-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/tls-cert-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/tls-cert-auth/update.mdx b/docs/api-reference/endpoints/tls-cert-auth/update.mdx new file mode 100644 index 000000000..3bb8892ea --- /dev/null +++ b/docs/api-reference/endpoints/tls-cert-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/tls-cert-auth/identities/{identityId}" +--- diff --git a/docs/docs.json b/docs/docs.json index e0d0db73a..ac9d62a7c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -288,6 +288,7 @@ "documentation/platform/identities/kubernetes-auth", "documentation/platform/identities/oci-auth", "documentation/platform/identities/token-auth", + "documentation/platform/identities/tls-cert-auth", "documentation/platform/identities/universal-auth", { "group": "OIDC Auth", @@ -752,6 +753,16 @@ "api-reference/endpoints/alicloud-auth/revoke" ] }, + { + "group": "TLS Certificate Auth", + "pages": [ + "api-reference/endpoints/tls-cert-auth/login", + "api-reference/endpoints/tls-cert-auth/attach", + "api-reference/endpoints/tls-cert-auth/retrieve", + "api-reference/endpoints/tls-cert-auth/update", + "api-reference/endpoints/tls-cert-auth/revoke" + ] + }, { "group": "AWS Auth", "pages": [ diff --git a/docs/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx new file mode 100644 index 000000000..e11d0c06e --- /dev/null +++ b/docs/documentation/platform/identities/tls-cert-auth.mdx @@ -0,0 +1,176 @@ +--- +title: TLS Certificate Auth +description: "Learn how to authenticate with Infisical using TLS Certificate." +--- + +**TLS Certificate Auth** is an authentication method that verifies a user's TLS Client certificate using the provided CA Certificate, allowing secure access to Infisical resources. + +## Diagram + +The following sequence diagram illustrates the TLS Certificate Auth workflow for authenticating users with Infisical. + +```mermaid +sequenceDiagram + participant Client + participant Infisical + + Note over Client,Client: Step 1: Setup your TLS request with the client certificate + + Note over Client,Infisical: Step 2: Login Operation + Client->>Infisical: Send request to /api/v1/auth/tls-cert-auth/login + + Note over Infisical: Step 3: Request verification using CA Certificate + + Infisical->>Client: Return short-lived access token + + Note over Client,Infisical: Step 5: Access Infisical API with token + Client->>Infisical: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high level, Infisical authenticates the client's TLS Certificate by verifying its identity and checking that it meets specific requirements (e.g., it is bound to the allowed common names) at the `/api/v1/auth/tls-cert-auth/login` endpoint. If successful, Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client sends a TLS request with the client certificate to Infisical at the `/api/v1/auth/tls-cert-auth/login` endpoint. +2. Infisical verifies the incoming request using the provided CA certificate. +3. Infisical checks the user's properties against set criteria such as Allowed Common Names. +4. If all checks pass, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + + Most of the time, the Infisical server will be behind a load balancer or + proxy. To propagate the TLS certificate from the load balancer to the + instance, you can configure the TLS to send the client certificate as a header + that is set as an [environment + variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key). + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on TLS Certificate to +access the Infisical API using request signing. + + + **Self-Hosted Users:** Before using TLS Certificate Auth, please review the + [Security Requirements for Self-Hosted + Deployments](#security-requirements-for-self-hosted-deployments) section below + to ensure proper configuration and avoid security vulnerabilities. + + +### Creating an identity + +To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**. + +![identities organization](/images/platform/identities/identities-org.png) + +When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). + +![identities organization create](/images/platform/identities/identities-org-create.png) + +Input some details for your new identity: + +- **Name (required):** A friendly name for the identity. +- **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to. + +Once you've created an identity, you'll be redirected to a page where you can manage the identity. + +![identities page](/images/platform/identities/identities-page.png) + +Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use TLS Certificate Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown. + +![identities press cog](/images/platform/identities/identities-press-cog.png) + +![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + +Now create a new TLS Certificate Auth Method. + +![identities create tls cert auth method](/images/platform/identities/identities-tls-cert-auth-create-auth.png) + +Here's some information about each field: + +- **CA Certificate:** A PEM encoded CA Certificate used to validate incoming TLS request client certificate. +- **Allowed Common Names:** A comma separated list of client certificate common names allowed. +- **Access Token TTL (default is `2592000` equivalent to 30 days):** The lifetime for an access token in seconds. This value will be referenced at renewal time. +- **Access Token Max TTL (default is `2592000` equivalent to 30 days):** The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. +- **Access Token Max Number of Uses (default is `0`):** The maximum number of times that an access token can be used; a value of `0` implies an infinite number of uses. +- **Access Token Trusted IPs:** The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + +### Adding an identity to a project + +In order to allow an identity to access project-level resources such as secrets, you must add it to the relevant projects. + +To do this, head over to the project you want to add the identity to and navigate to Project Settings > Access Control > Machine Identities and press **Add Identity**. + +![identities project](/images/platform/identities/identities-project.png) + +Select the identity you want to add to the project and the project-level role you want it to assume. The project role given to the identity will determine what project-level resources this identity can access. + +![identities project create](/images/platform/identities/identities-project-create.png) + +### Accessing the Infisical API with the identity + +To access the Infisical API as the identity, you need to send a TLS request to `/api/v1/auth/tls-cert-auth/login` endpoint. + +Below is an example of how you can authenticate with Infisical using NodeJS. + +```javascript +const fs = require("fs"); +const https = require("https"); +const axios = require("axios"); + +try { + const clientCertificate = fs.readFileSync("client-cert.pem", "utf8"); + const clientKeyCertificate = fs.readFileSync("client-key.pem", "utf8"); + + const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL + const identityId = ""; + + // Create HTTPS agent with client certificate and key + const httpsAgent = new https.Agent({ + cert: clientCertificate, + key: clientKeyCertificate, + }); + + const { data } = await axios.post( + `${infisicalUrl}/api/v1/auth/tls-cert-auth/login`, + { + identityId, + }, + { + httpsAgent: httpsAgent, // Pass the HTTPS agent with client cert + } + ); + + console.log("result data: ", data); // access token here +} catch (err) { + console.error(err); +} +``` + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted. + + If an identity access token expires, it can no longer access the Infisical API. A new access token should be obtained by performing another login operation. + + + +## Security Requirements for Self-Hosted Deployments + +ALL TLS cert [login](/api-reference/endpoints/tls-cert-auth/login) requests **MUST** go through a load balancer/proxy that verifies certificate ownership: + +- **REQUIRED:** Configure your load balancer/proxy to **require a proper TLS handshake with client certificate presentation** +- **REQUIRED:** Ensure the load balancer **verifies the client possesses the private key** corresponding to the certificate (standard TLS behavior) +- **NEVER** allow direct connections to Infisical for TLS cert auth - this enables header injection attacks +- **NEVER** forward certificate headers without requiring proper TLS certificate presentation + +### Load Balancer Configuration Examples + +- **AWS ALB:** Use mTLS listeners which require client certificate presentation during the TLS handshake +- **NGINX/HAProxy:** Configure SSL client certificate requirement with proper TLS handshake verification + + + Infisical will handle the actual certificate validation against the configured + CA certificate and determine authentication permissions. The load balancer's + role is to ensure certificate ownership, not certificate trust validation. + diff --git a/docs/favicon.png b/docs/favicon.png index 45c9b868e..2a2ec8d1c 100644 Binary files a/docs/favicon.png and b/docs/favicon.png differ diff --git a/docs/images/platform/identities/identities-tls-cert-auth-create-auth.png b/docs/images/platform/identities/identities-tls-cert-auth-create-auth.png new file mode 100644 index 000000000..2e66633e7 Binary files /dev/null and b/docs/images/platform/identities/identities-tls-cert-auth-create-auth.png differ diff --git a/docs/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png b/docs/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png new file mode 100644 index 000000000..ac71e0243 Binary files /dev/null and b/docs/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png differ diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index abc52d971..32fd0c12f 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -148,3 +148,11 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." ``` + +## FAQ + + + + The path is required and will be prepended to the key schema. For example, if you have a path of `/demo/path/` and a key schema of `INFISICAL_{{secretKey}}`, then the result will be `/demo/path/INFISICAL_{{secretKey}}`. + + diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index 0be08a9a9..afce51a0c 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -7,9 +7,10 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create a [GCP Connection](/integrations/app-connections/gcp) with the required **Secret Sync** permissions - - Enable **Cloud Resource Manager API** and **Secret Manager API** on your GCP project + - Enable **Cloud Resource Manager API**, **Secret Manager API**, and **Service Usage API** on your GCP project ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png) + ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-service-usage-api.png) diff --git a/docs/logo/dark.svg b/docs/logo/dark.svg index f88594746..35da75049 100644 --- a/docs/logo/dark.svg +++ b/docs/logo/dark.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/docs/logo/light.svg b/docs/logo/light.svg index 16fc09e5e..0ab925e3b 100644 --- a/docs/logo/light.svg +++ b/docs/logo/light.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index f5d1c58d0..90f3b2207 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -32,7 +32,7 @@ Used to configure platform-specific security and operational settings 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. +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. @@ -122,6 +122,7 @@ DB_READ_REPLICAS=[{"DB_CONNECTION_URI":""}] ### Redis + Redis is used for caching and background tasks. You can use either a standalone Redis instance or a Redis Sentinel setup. @@ -199,8 +200,17 @@ Without email configuration, Infisical's core functions like sign-up/login and s connection can not be encrypted then message is not sent. - - If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use. + + If this is `true`, Infisical will validate the server's SSL/TLS certificate + and reject the connection if the certificate is invalid or not trusted. If set + to `false`, the client will accept the server's certificate regardless of its + validity, which can be useful in development or testing environments but is + not recommended for production use. @@ -211,6 +221,7 @@ Without email configuration, Infisical's core functions like sign-up/login and s Infisical highly encourages the following variables be used alongside this one for maximum security: - `SMTP_REQUIRE_TLS=true` - `SMTP_TLS_REJECT_UNAUTHORIZED=true` + @@ -577,6 +588,7 @@ You can configure third-party app connections for re-use across Infisical Projec The webhook secret configured for payload verification in the GitHub Radar App + @@ -771,3 +783,14 @@ If export type is set to `otlp`, you will have to configure a value for `OTEL_EX The password for authenticating with the telemetry collector. + +## Identity Auth Method + + + The TLS header used to propagate the client certificate from the load balancer + to the server. + diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx index 68866dcbd..7bae0479f 100644 --- a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -58,6 +58,7 @@ export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...prop } onPointerDownOutside={(e) => e.preventDefault()} className="max-w-2xl" + bodyClassName="overflow-visible" subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."} > { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", { + initPerPage: 16 + }); + + const filteredOptions = useMemo( + () => + secretSyncOptions?.filter( + ({ name, destination }) => + name?.toLowerCase().includes(search.trim().toLowerCase()) || + destination.toLowerCase().includes(search.toLowerCase()) + ) ?? [], + [secretSyncOptions, search] + ); + + useResetPageHelper({ + totalCount: filteredOptions.length, + offset, + setPage + }); + if (isPending) { return (
@@ -29,75 +50,103 @@ export const SecretSyncSelect = ({ onSelect }: Props) => { } return ( -
- {secretSyncOptions?.map(({ destination, enterprise }) => { - const { image, name } = SECRET_SYNC_MAP[destination]; - return ( - - ); - })} +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search options..." + className="bg-mineshaft-800 placeholder:text-mineshaft-400" + /> +
+ {filteredOptions.slice(offset, perPage * page)?.map(({ destination, enterprise }) => { + const { image, name } = SECRET_SYNC_MAP[destination]; + return ( + + ); + })} + {!filteredOptions?.length && ( + + )} +
+ {Boolean(filteredOptions.length) && ( + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ + Don't see the third-party service you're looking for? + + +
+ + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} handlePopUpToggle("upgradePlan", isOpen)} text="You can use every Secret Sync if you switch to Infisical's Enterprise plan." /> - -

Infisical is constantly adding support for more services.

-

- {`If you don't see the third-party - service you're looking for,`}{" "} - - let us know on Slack - {" "} - or{" "} - - make a request on GitHub - - . -

- - } - > -
- -
- Coming Soon -
-
-
); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx index 47975ca37..b471b66a9 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx @@ -30,7 +30,29 @@ export const AwsParameterStoreSyncFields = () => { /> ( - + + The path is required and will be prepended to the key schema. For example, if you + have a path of{" "} + + /demo/path/ + {" "} + and a key schema of{" "} + + INFISICAL_{"{{secretKey}}"} + + , then the result will be{" "} + + /demo/path/INFISICAL_{"{{secretKey}}"} + + + } + tooltipClassName="max-w-lg" + > )} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx index 3361b8384..e7d988f46 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx @@ -76,7 +76,7 @@ export const GcpSyncFields = () => { helperText={
Don't see the project you're looking for?{" "} diff --git a/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx b/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx index 95eaf5745..b746a32d7 100644 --- a/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx +++ b/frontend/src/components/v2/GenericFieldLabel/GenericFieldLabel.tsx @@ -1,4 +1,6 @@ import { ReactNode } from "react"; +import { IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; type Props = { @@ -7,6 +9,7 @@ type Props = { className?: string; labelClassName?: string; truncate?: boolean; + icon?: IconDefinition; }; export const GenericFieldLabel = ({ @@ -14,11 +17,15 @@ export const GenericFieldLabel = ({ children, className, labelClassName, - truncate + truncate, + icon }: Props) => { return (
-

{label}

+
+ {icon && } +

{label}

+
{children ? (

{children}

) : ( diff --git a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx index 4ce4a7ffa..14c99552e 100644 --- a/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx +++ b/frontend/src/components/v2/PasswordGenerator/PasswordGenerator.tsx @@ -163,7 +163,7 @@ const PasswordGeneratorModal = ({
setPasswordOptions({ ...passwordOptions, useUppercase: checked as boolean }) @@ -174,7 +174,7 @@ const PasswordGeneratorModal = ({ setPasswordOptions({ ...passwordOptions, useLowercase: checked as boolean }) @@ -185,7 +185,7 @@ const PasswordGeneratorModal = ({ setPasswordOptions({ ...passwordOptions, useNumbers: checked as boolean }) @@ -196,7 +196,7 @@ const PasswordGeneratorModal = ({ setPasswordOptions({ ...passwordOptions, useSpecialChars: checked as boolean }) diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index cc8d0cef6..b441f5015 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -11,5 +11,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.OCI_AUTH]: "OCI Auth", [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", [IdentityAuthMethod.LDAP_AUTH]: "LDAP Auth", - [IdentityAuthMethod.JWT_AUTH]: "JWT Auth" + [IdentityAuthMethod.JWT_AUTH]: "JWT Auth", + [IdentityAuthMethod.TLS_CERT_AUTH]: "TLS Certificate Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index de329d393..c850005cf 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -9,7 +9,8 @@ export enum IdentityAuthMethod { OCI_AUTH = "oci-auth", OIDC_AUTH = "oidc-auth", LDAP_AUTH = "ldap-auth", - JWT_AUTH = "jwt-auth" + JWT_AUTH = "jwt-auth", + TLS_CERT_AUTH = "tls-cert-auth" } export enum IdentityJwtConfigurationType { diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 7abf1a015..dfbf574e8 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -14,6 +14,7 @@ import { AddIdentityLdapAuthDTO, AddIdentityOciAuthDTO, AddIdentityOidcAuthDTO, + AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, ClientSecretData, @@ -32,6 +33,7 @@ import { DeleteIdentityLdapAuthDTO, DeleteIdentityOciAuthDTO, DeleteIdentityOidcAuthDTO, + DeleteIdentityTlsCertAuthDTO, DeleteIdentityTokenAuthDTO, DeleteIdentityUniversalAuthClientSecretDTO, DeleteIdentityUniversalAuthDTO, @@ -46,6 +48,7 @@ import { IdentityLdapAuth, IdentityOciAuth, IdentityOidcAuth, + IdentityTlsCertAuth, IdentityTokenAuth, IdentityUniversalAuth, RevokeTokenDTO, @@ -60,6 +63,7 @@ import { UpdateIdentityLdapAuthDTO, UpdateIdentityOciAuthDTO, UpdateIdentityOidcAuthDTO, + UpdateIdentityTlsCertAuthDTO, UpdateIdentityTokenAuthDTO, UpdateIdentityUniversalAuthDTO, UpdateTokenIdentityTokenAuthDTO @@ -655,6 +659,107 @@ export const useDeleteIdentityAliCloudAuth = () => { }); }; +export const useAddIdentityTlsCertAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + allowedCommonNames, + caCertificate, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityTlsCertAuth } + } = await apiRequest.post<{ identityTlsCertAuth: IdentityTlsCertAuth }>( + `/api/v1/auth/tls-cert-auth/identities/${identityId}`, + { + allowedCommonNames, + caCertificate, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityTlsCertAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId) + }); + } + }); +}; + +export const useUpdateIdentityTlsCertAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + allowedCommonNames, + caCertificate, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityTlsCertAuth } + } = await apiRequest.patch<{ identityTlsCertAuth: IdentityTlsCertAuth }>( + `/api/v1/auth/tls-cert-auth/identities/${identityId}`, + { + caCertificate, + allowedCommonNames, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityTlsCertAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId) + }); + } + }); +}; + +export const useDeleteIdentityTlsCertAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identityTlsCertAuth } + } = await apiRequest.delete(`/api/v1/auth/tls-cert-auth/identities/${identityId}`); + return identityTlsCertAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId) + }); + } + }); +}; + export const useUpdateIdentityOidcAuth = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 806c1f0a4..b59526da1 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -17,6 +17,7 @@ import { IdentityMembershipOrg, IdentityOciAuth, IdentityOidcAuth, + IdentityTlsCertAuth, IdentityTokenAuth, IdentityUniversalAuth, TSearchIdentitiesDTO @@ -34,6 +35,8 @@ export const identitiesKeys = { getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, getIdentityOidcAuth: (identityId: string) => [{ identityId }, "identity-oidc-auth"] as const, getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityTlsCertAuth: (identityId: string) => + [{ identityId }, "identity-tls-cert-auth"] as const, getIdentityAliCloudAuth: (identityId: string) => [{ identityId }, "identity-alicloud-auth"] as const, getIdentityOciAuth: (identityId: string) => [{ identityId }, "identity-oci-auth"] as const, @@ -175,6 +178,27 @@ export const useGetIdentityAwsAuth = ( }); }; +export const useGetIdentityTlsCertAuth = ( + identityId: string, + options?: TReactQueryOptions["options"] +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId), + queryFn: async () => { + const { + data: { identityTlsCertAuth } + } = await apiRequest.get<{ identityTlsCertAuth: IdentityTlsCertAuth }>( + `/api/v1/auth/tls-cert-auth/identities/${identityId}` + ); + return identityTlsCertAuth; + }, + staleTime: 0, + gcTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; + export const useGetIdentityOciAuth = ( identityId: string, options?: TReactQueryOptions["options"] diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 873338f09..1af1cc24c 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -485,6 +485,47 @@ export type DeleteIdentityKubernetesAuthDTO = { identityId: string; }; +export type IdentityTlsCertAuth = { + identityId: string; + caCertificate: string; + allowedCommonNames: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityTlsCertAuthDTO = { + organizationId: string; + identityId: string; + caCertificate: string; + allowedCommonNames?: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityTlsCertAuthDTO = { + organizationId: string; + identityId: string; + caCertificate: string; + allowedCommonNames?: string | null; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityTlsCertAuthDTO = { + organizationId: string; + identityId: string; +}; + export type CreateIdentityUniversalAuthClientSecretDTO = { identityId: string; description?: string; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx index 10c1fa4cd..a10743023 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx @@ -446,7 +446,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { { @@ -481,7 +480,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplateModal.tsx index 475c15b20..d709458f0 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplateModal.tsx @@ -405,7 +405,6 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro { onChange({ @@ -439,7 +438,6 @@ export const CertificateTemplateModal = ({ popUp, handlePopUpToggle, caId }: Pro { onChange({ diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx index 5d5e9d186..84edc90c1 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -408,7 +408,6 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { { onChange({ @@ -443,7 +442,6 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { { onChange({ @@ -477,12 +475,7 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { errorText={error?.message} tooltipText="If enabled, a new certificate will be issued automatically X days before the current certificate expires." > - + Enable Certificate Auto Renewal diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx index e4ae574cd..f52c83725 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx @@ -333,7 +333,6 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => { { onChange({ @@ -367,7 +366,6 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => { { onChange({ diff --git a/frontend/src/pages/kms/KmipPage/components/KmipClientModal.tsx b/frontend/src/pages/kms/KmipPage/components/KmipClientModal.tsx index 4bab2c10f..8c959c7b0 100644 --- a/frontend/src/pages/kms/KmipPage/components/KmipClientModal.tsx +++ b/frontend/src/pages/kms/KmipPage/components/KmipClientModal.tsx @@ -145,7 +145,6 @@ const KmipClientForm = ({ onComplete, kmipClient }: FormProps) => { { onChange({ diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 0d44bfe64..57663f441 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -17,6 +17,7 @@ import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityLdapAuthForm } from "./IdentityLdapAuthForm"; import { IdentityOciAuthForm } from "./IdentityOciAuthForm"; import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; +import { IdentityTlsCertAuthForm } from "./IdentityTlsCertAuthForm"; import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; @@ -52,6 +53,7 @@ const identityAuthMethods = [ { label: "OCI Auth", value: IdentityAuthMethod.OCI_AUTH }, { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH }, { label: "LDAP Auth", value: IdentityAuthMethod.LDAP_AUTH }, + { label: "TLS Certificate Auth", value: IdentityAuthMethod.TLS_CERT_AUTH }, { label: "JWT Auth", value: IdentityAuthMethod.JWT_AUTH @@ -123,6 +125,15 @@ export const IdentityAuthMethodModalContent = ({ /> ) }, + [IdentityAuthMethod.TLS_CERT_AUTH]: { + render: () => ( + + ) + }, [IdentityAuthMethod.OIDC_AUTH]: { render: () => ( diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx new file mode 100644 index 000000000..027620d6b --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx @@ -0,0 +1,358 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Tab, + TabList, + TabPanel, + Tabs, + TextArea +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityTlsCertAuth, + useGetIdentityTlsCertAuth, + useUpdateIdentityTlsCertAuth +} from "@app/hooks/api"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { IdentityFormTab } from "./types"; + +const schema = z.object({ + allowedCommonNames: z.string().optional(), + caCertificate: z.string().min(1), + accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token TTL cannot be greater than 315360000" + }), + accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token Max TTL cannot be greater than 315360000" + }), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1) +}); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityId?: string; + isUpdate?: boolean; +}; + +export const IdentityTlsCertAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityId, + isUpdate +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityTlsCertAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityTlsCertAuth(); + const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + + const { data } = useGetIdentityTlsCertAuth(identityId ?? "", { + enabled: isUpdate + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + caCertificate: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + caCertificate: data.caCertificate, + allowedCommonNames: data.allowedCommonNames || undefined, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + caCertificate: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + caCertificate, + allowedCommonNames, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityId) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + caCertificate, + allowedCommonNames: allowedCommonNames || null, + identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId, + caCertificate, + allowedCommonNames: allowedCommonNames || undefined, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ setTabValue(value as IdentityFormTab)}> + + Configuration + Advanced + + + ( + +