diff --git a/.env.example b/.env.example index 8ee3d2001..23f845b71 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,14 @@ INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= INF_APP_CONNECTION_GITHUB_APP_SLUG= INF_APP_CONNECTION_GITHUB_APP_ID= +#github radar app connection +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET= +INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY= +INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG= +INF_APP_CONNECTION_GITHUB_RADAR_APP_ID= +INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= + #gcp app connection INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= diff --git a/.infisicalignore b/.infisicalignore index 02cdd4f0e..7c203945f 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -40,3 +40,4 @@ cli/detect/config/gitleaks.toml:gcp-api-key:578 cli/detect/config/gitleaks.toml:gcp-api-key:579 cli/detect/config/gitleaks.toml:gcp-api-key:581 cli/detect/config/gitleaks.toml:gcp-api-key:582 +backend/src/services/smtp/smtp-service.ts:generic-api-key:79 diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 1871bab39..d13d52663 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -38,6 +38,7 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { TSecretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; @@ -85,6 +86,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -274,7 +276,9 @@ declare module "fastify" { githubOrgSync: TGithubOrgSyncServiceFactory; folderCommit: TFolderCommitServiceFactory; pit: TPitServiceFactory; + secretScanningV2: TSecretScanningV2ServiceFactory; internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; + pkiTemplate: TPkiTemplatesServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2b731be16..8fd073a49 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -6,6 +6,9 @@ import { TAccessApprovalPoliciesApprovers, TAccessApprovalPoliciesApproversInsert, TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate, TAccessApprovalPoliciesInsert, TAccessApprovalPoliciesUpdate, TAccessApprovalRequests, @@ -294,6 +297,9 @@ import { TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate, + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate, TSecretApprovalPoliciesInsert, TSecretApprovalPoliciesUpdate, TSecretApprovalRequests, @@ -348,9 +354,24 @@ import { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate, TSecrets, + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate, + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate, + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate, TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate, + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate, TSecretSharing, TSecretSharingInsert, TSecretSharingUpdate, @@ -838,6 +859,12 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesApproversUpdate >; + [TableName.AccessApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate + >; + [TableName.AccessApprovalRequest]: KnexOriginal.CompositeTableType< TAccessApprovalRequests, TAccessApprovalRequestsInsert, @@ -861,6 +888,11 @@ declare module "knex/types/tables" { TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate >; + [TableName.SecretApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate + >; [TableName.SecretApprovalRequest]: KnexOriginal.CompositeTableType< TSecretApprovalRequests, TSecretApprovalRequestsInsert, @@ -1138,5 +1170,30 @@ declare module "knex/types/tables" { TFolderTreeCheckpointResourcesInsert, TFolderTreeCheckpointResourcesUpdate >; + [TableName.SecretScanningDataSource]: KnexOriginal.CompositeTableType< + TSecretScanningDataSources, + TSecretScanningDataSourcesInsert, + TSecretScanningDataSourcesUpdate + >; + [TableName.SecretScanningResource]: KnexOriginal.CompositeTableType< + TSecretScanningResources, + TSecretScanningResourcesInsert, + TSecretScanningResourcesUpdate + >; + [TableName.SecretScanningScan]: KnexOriginal.CompositeTableType< + TSecretScanningScans, + TSecretScanningScansInsert, + TSecretScanningScansUpdate + >; + [TableName.SecretScanningFinding]: KnexOriginal.CompositeTableType< + TSecretScanningFindings, + TSecretScanningFindingsInsert, + TSecretScanningFindingsUpdate + >; + [TableName.SecretScanningConfig]: KnexOriginal.CompositeTableType< + TSecretScanningConfigs, + TSecretScanningConfigsInsert, + TSecretScanningConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts new file mode 100644 index 000000000..86da451f3 --- /dev/null +++ b/backend/src/db/migrations/20250517002225_secret-scanning-v2.ts @@ -0,0 +1,107 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretScanningDataSource))) { + await knex.schema.createTable(TableName.SecretScanningDataSource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").index(); // if we need a unique way of identifying this data source from an external resource + t.string("name", 48).notNullable(); + t.string("description"); + t.string("type").notNullable(); + t.jsonb("config").notNullable(); + t.binary("encryptedCredentials"); // webhook credentials, etc. + t.uuid("connectionId"); + t.boolean("isAutoScanEnabled").defaultTo(true); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.boolean("isDisconnected").notNullable().defaultTo(false); + t.unique(["projectId", "name"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningResource))) { + await knex.schema.createTable(TableName.SecretScanningResource, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("externalId").notNullable(); + t.string("name").notNullable(); + t.string("type").notNullable(); + t.uuid("dataSourceId").notNullable(); + t.foreign("dataSourceId").references("id").inTable(TableName.SecretScanningDataSource).onDelete("CASCADE"); + t.timestamps(true, true, true); + t.unique(["dataSourceId", "externalId"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningResource); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningScan))) { + await knex.schema.createTable(TableName.SecretScanningScan, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("status").notNullable().defaultTo(SecretScanningScanStatus.Queued); + t.string("statusMessage", 1024); + t.string("type").notNullable(); + t.uuid("resourceId").notNullable(); + t.foreign("resourceId").references("id").inTable(TableName.SecretScanningResource).onDelete("CASCADE"); + t.timestamp("createdAt").defaultTo(knex.fn.now()); + }); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningFinding))) { + await knex.schema.createTable(TableName.SecretScanningFinding, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("dataSourceName").notNullable(); + t.string("dataSourceType").notNullable(); + t.string("resourceName").notNullable(); + t.string("resourceType").notNullable(); + t.string("rule").notNullable(); + t.string("severity").notNullable(); + t.string("status").notNullable().defaultTo(SecretScanningFindingStatus.Unresolved); + t.string("remarks"); + t.string("fingerprint").notNullable(); + t.jsonb("details").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("scanId"); + t.foreign("scanId").references("id").inTable(TableName.SecretScanningScan).onDelete("SET NULL"); + t.timestamps(true, true, true); + t.unique(["projectId", "fingerprint"]); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningFinding); + } + + if (!(await knex.schema.hasTable(TableName.SecretScanningConfig))) { + await knex.schema.createTable(TableName.SecretScanningConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable().unique(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("content", 5000); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretScanningConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretScanningFinding); + + await dropOnUpdateTrigger(knex, TableName.SecretScanningFinding); + await knex.schema.dropTableIfExists(TableName.SecretScanningScan); + + await knex.schema.dropTableIfExists(TableName.SecretScanningResource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningResource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningDataSource); + await dropOnUpdateTrigger(knex, TableName.SecretScanningDataSource); + + await knex.schema.dropTableIfExists(TableName.SecretScanningConfig); + await dropOnUpdateTrigger(knex, TableName.SecretScanningConfig); +} diff --git a/backend/src/db/migrations/20250527030702_policy-bypassers.ts b/backend/src/db/migrations/20250527030702_policy-bypassers.ts new file mode 100644 index 000000000..98b1f4be1 --- /dev/null +++ b/backend/src/db/migrations/20250527030702_policy-bypassers.ts @@ -0,0 +1,48 @@ +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.AccessApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); + } + + if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.SecretApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.SecretApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicyBypasser); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyBypasser); + + await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); +} diff --git a/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts new file mode 100644 index 000000000..2ff493c6f --- /dev/null +++ b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.string("usernameTemplate").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.dropColumn("usernameTemplate"); + }); + } +} diff --git a/backend/src/db/migrations/20250528145356_add-template-slug.ts b/backend/src/db/migrations/20250528145356_add-template-slug.ts new file mode 100644 index 000000000..34a7e38f8 --- /dev/null +++ b/backend/src/db/migrations/20250528145356_add-template-slug.ts @@ -0,0 +1,24 @@ +import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; + +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasNameCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "name"); + if (hasNameCol) { + const templates = await knex(TableName.CertificateTemplate).select("id", "name"); + await Promise.all( + templates.map((el) => { + const slugifiedName = el.name + ? slugify(`${el.name.slice(0, 16)}-${alphaNumericNanoId(8)}`) + : slugify(alphaNumericNanoId(12)); + + return knex(TableName.CertificateTemplate).where({ id: el.id }).update({ name: slugifiedName }); + }) + ); + } +} + +export async function down(): Promise {} diff --git a/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts b/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts new file mode 100644 index 000000000..547f1d1a7 --- /dev/null +++ b/backend/src/db/migrations/20250530152721_add-access-approval-request-deleted-at.ts @@ -0,0 +1,63 @@ +import { Knex } from "knex"; + +import { ApprovalStatus } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPrivilegeDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeDeletedAt" + ); + const hasStatusColumn = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "status"); + + if (!hasPrivilegeDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.timestamp("privilegeDeletedAt").nullable(); + }); + } + + if (!hasStatusColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.string("status").defaultTo(ApprovalStatus.PENDING).notNullable(); + }); + + // Update existing rows based on business logic + // If privilegeId is not null, set status to "approved" + await knex(TableName.AccessApprovalRequest).whereNotNull("privilegeId").update({ status: ApprovalStatus.APPROVED }); + + // If privilegeId is null and there's a rejected reviewer, set to "rejected" + const rejectedRequestIds = await knex(TableName.AccessApprovalRequestReviewer) + .select("requestId") + .where("status", "rejected") + .distinct() + .pluck("requestId"); + + if (rejectedRequestIds.length > 0) { + await knex(TableName.AccessApprovalRequest) + .whereNull("privilegeId") + .whereIn("id", rejectedRequestIds) + .update({ status: ApprovalStatus.REJECTED }); + } + } +} + +export async function down(knex: Knex): Promise { + const hasPrivilegeDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalRequest, + "privilegeDeletedAt" + ); + const hasStatusColumn = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "status"); + + if (hasPrivilegeDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropColumn("privilegeDeletedAt"); + }); + } + + if (hasStatusColumn) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropColumn("status"); + }); + } +} diff --git a/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts index 9668b8623..dd4034c57 100644 --- a/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts +++ b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts @@ -209,7 +209,7 @@ export async function up(knex: Knex): Promise { } logger.info(`Retrieved folder changes for project batch ${i} of ${batches.length}`); - const filteredBrokenProjectFolders = []; + const filteredBrokenProjectFolders: string[] = []; foldersCommitsList = foldersCommitsList.filter((folderCommit) => { if (!envRootFoldersMap[folderCommit.commit.envId]) { diff --git a/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts new file mode 100644 index 000000000..da5493153 --- /dev/null +++ b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (!hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.string("tokenReviewMode").notNullable().defaultTo("api"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropColumn("tokenReviewMode"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies-bypassers.ts b/backend/src/db/schemas/access-approval-policies-bypassers.ts new file mode 100644 index 000000000..278e4b416 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesBypassers = z.infer; +export type TAccessApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesBypassersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index bfe990b3a..6a6f09148 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -18,7 +18,9 @@ export const AccessApprovalRequestsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), requestedByUserId: z.string().uuid(), - note: z.string().nullable().optional() + note: z.string().nullable().optional(), + privilegeDeletedAt: z.date().nullable().optional(), + status: z.string().default("pending") }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index 350a32b7a..637d0c632 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -28,7 +28,8 @@ export const DynamicSecretsSchema = z.object({ updatedAt: z.date(), encryptedInput: zodBuffer, projectGatewayId: z.string().uuid().nullable().optional(), - gatewayId: z.string().uuid().nullable().optional() + gatewayId: z.string().uuid().nullable().optional(), + usernameTemplate: z.string().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 00d1fd771..8a351014a 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -31,7 +31,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), gatewayId: z.string().uuid().nullable().optional(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + tokenReviewMode: z.string().default("api") }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 654e594ec..7a27caf9e 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,5 +1,6 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; +export * from "./access-approval-policies-bypassers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; @@ -98,6 +99,7 @@ export * from "./saml-configs"; export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; +export * from "./secret-approval-policies-bypassers"; export * from "./secret-approval-request-secret-tags"; export * from "./secret-approval-request-secret-tags-v2"; export * from "./secret-approval-requests"; @@ -115,7 +117,12 @@ export * from "./secret-rotation-outputs"; export * from "./secret-rotation-v2-secret-mappings"; export * from "./secret-rotations"; export * from "./secret-rotations-v2"; +export * from "./secret-scanning-configs"; +export * from "./secret-scanning-data-sources"; +export * from "./secret-scanning-findings"; export * from "./secret-scanning-git-risks"; +export * from "./secret-scanning-resources"; +export * from "./secret-scanning-scans"; export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 9c26cba16..df0a858b9 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -95,10 +95,12 @@ export enum TableName { ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalPolicyBypasser = "access_approval_policies_bypassers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", + SecretApprovalPolicyBypasser = "secret_approval_policies_bypassers", SecretApprovalRequest = "secret_approval_requests", SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", @@ -163,7 +165,12 @@ export enum TableName { FolderCheckpoint = "folder_checkpoints", FolderCheckpointResources = "folder_checkpoint_resources", FolderTreeCheckpoint = "folder_tree_checkpoints", - FolderTreeCheckpointResources = "folder_tree_checkpoint_resources" + FolderTreeCheckpointResources = "folder_tree_checkpoint_resources", + SecretScanningDataSource = "secret_scanning_data_sources", + SecretScanningResource = "secret_scanning_resources", + SecretScanningScan = "secret_scanning_scans", + SecretScanningFinding = "secret_scanning_findings", + SecretScanningConfig = "secret_scanning_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; @@ -252,7 +259,8 @@ export enum ProjectType { SecretManager = "secret-manager", CertificateManager = "cert-manager", KMS = "kms", - SSH = "ssh" + SSH = "ssh", + SecretScanning = "secret-scanning" } export enum ActionProjectType { @@ -260,6 +268,7 @@ export enum ActionProjectType { CertificateManager = ProjectType.CertificateManager, KMS = ProjectType.KMS, SSH = ProjectType.SSH, + SecretScanning = ProjectType.SecretScanning, // project operations that happen on all types Any = "any" } diff --git a/backend/src/db/schemas/secret-approval-policies-bypassers.ts b/backend/src/db/schemas/secret-approval-policies-bypassers.ts new file mode 100644 index 000000000..86eea45d3 --- /dev/null +++ b/backend/src/db/schemas/secret-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretApprovalPoliciesBypassers = z.infer; +export type TSecretApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalPoliciesBypassersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-configs.ts b/backend/src/db/schemas/secret-scanning-configs.ts new file mode 100644 index 000000000..c3719d352 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-configs.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + content: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningConfigs = z.infer; +export type TSecretScanningConfigsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-scanning-data-sources.ts b/backend/src/db/schemas/secret-scanning-data-sources.ts new file mode 100644 index 000000000..d79b45e79 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-data-sources.ts @@ -0,0 +1,32 @@ +// 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 SecretScanningDataSourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + type: z.string(), + config: z.unknown(), + encryptedCredentials: zodBuffer.nullable().optional(), + connectionId: z.string().uuid().nullable().optional(), + isAutoScanEnabled: z.boolean().default(true).nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + isDisconnected: z.boolean().default(false) +}); + +export type TSecretScanningDataSources = z.infer; +export type TSecretScanningDataSourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningDataSourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-findings.ts b/backend/src/db/schemas/secret-scanning-findings.ts new file mode 100644 index 000000000..c36f229f8 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-findings.ts @@ -0,0 +1,32 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningFindingsSchema = z.object({ + id: z.string().uuid(), + dataSourceName: z.string(), + dataSourceType: z.string(), + resourceName: z.string(), + resourceType: z.string(), + rule: z.string(), + severity: z.string(), + status: z.string().default("unresolved"), + remarks: z.string().nullable().optional(), + fingerprint: z.string(), + details: z.unknown(), + projectId: z.string(), + scanId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningFindings = z.infer; +export type TSecretScanningFindingsInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningFindingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-resources.ts b/backend/src/db/schemas/secret-scanning-resources.ts new file mode 100644 index 000000000..e791e1cd6 --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-resources.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningResourcesSchema = z.object({ + id: z.string().uuid(), + externalId: z.string(), + name: z.string(), + type: z.string(), + dataSourceId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretScanningResources = z.infer; +export type TSecretScanningResourcesInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningResourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-scanning-scans.ts b/backend/src/db/schemas/secret-scanning-scans.ts new file mode 100644 index 000000000..88e676b5b --- /dev/null +++ b/backend/src/db/schemas/secret-scanning-scans.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretScanningScansSchema = z.object({ + id: z.string().uuid(), + status: z.string().default("queued"), + statusMessage: z.string().nullable().optional(), + type: z.string(), + resourceId: z.string().uuid(), + createdAt: z.date().nullable().optional() +}); + +export type TSecretScanningScans = z.infer; +export type TSecretScanningScansInsert = Omit, TImmutableDBKeys>; +export type TSecretScanningScansUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 97a819234..2553a0efc 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -24,10 +24,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() + .max(100, "Cannot have more than 100 approvers") .min(1, { message: "At least one approver should be provided" }), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -72,7 +81,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() }) .array() .nullable() - .optional() + .optional(), + bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() }) .array() .nullable() @@ -143,10 +153,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -220,6 +239,15 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .nullable() + .optional(), + bypassers: z + .object({ + type: z.nativeEnum(BypasserType), + id: z.string().nullable().optional(), + name: z.string().nullable().optional() + }) + .array() + .nullable() .optional() }) }) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index d90f28184..5b3a08b4b 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -113,6 +113,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: z.string().array(), + bypassers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), enforcementLevel: z.string(), diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 6e70effe4..bf5cce7d5 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -6,6 +6,8 @@ import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; +import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -13,6 +15,28 @@ import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchema import { AuthMode } from "@app/services/auth/auth-type"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; +const validateUsernameTemplateCharacters = characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Underscore, + CharacterType.Hyphen, + CharacterType.OpenBrace, + CharacterType.CloseBrace, + CharacterType.CloseBracket, + CharacterType.OpenBracket, + CharacterType.Fullstop +]); + +const userTemplateSchema = z + .string() + .trim() + .max(255) + .refine((el) => validateUsernameTemplateCharacters(el)) + .refine((el) => + isValidHandleBarTemplate(el, { + allowedExpressions: (val) => ["randomUsername", "unixTimestamp"].includes(val) + }) + ); + export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -52,7 +76,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.optional() }), response: { 200: z.object({ @@ -73,39 +98,6 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => } }); - server.route({ - method: "POST", - url: "/entra-id/users", - config: { - rateLimit: readLimit - }, - schema: { - body: z.object({ - tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), - applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), - clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") - }), - response: { - 200: z - .object({ - name: z.string().min(1).describe("The name of the user"), - id: z.string().min(1).describe("The ID of the user"), - email: z.string().min(1).describe("The email of the user") - }) - .array() - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ - tenantId: req.body.tenantId, - applicationId: req.body.applicationId, - clientSecret: req.body.clientSecret - }); - return data; - } - }); - server.route({ method: "PATCH", url: "/:name", @@ -150,7 +142,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }) .nullable(), newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.nullable().optional() }) }), response: { @@ -328,4 +321,37 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => return { leases }; } }); + + server.route({ + method: "POST", + url: "/entra-id/users", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), + applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), + clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") + }), + response: { + 200: z + .object({ + name: z.string().min(1).describe("The name of the user"), + id: z.string().min(1).describe("The ID of the user"), + email: z.string().min(1).describe("The email of the user") + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ + tenantId: req.body.tenantId, + applicationId: req.body.applicationId, + clientSecret: req.body.clientSecret + }); + return data; + } + }); }; diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index 846b60923..ebe1345b3 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { removeTrailingSlash } from "@app/lib/fn"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -30,10 +30,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -75,10 +84,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), secretPath: z .string() @@ -157,6 +175,12 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType) }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType) + }) .array() }) .array() @@ -193,7 +217,14 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .object({ id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType), - name: z.string().nullable().optional() + username: z.string().nullable().optional() + }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType), + username: z.string().nullable().optional() }) .array() }) diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 7d2cdcc0c..eed5cd34a 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -47,6 +47,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv userId: z.string().nullable().optional() }) .array(), + bypassers: z + .object({ + userId: z.string().nullable().optional() + }) + .array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), @@ -266,6 +271,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: approvalRequestUser.array(), + bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index 70e5005a4..e364f4949 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -2,6 +2,10 @@ import { registerSecretRotationV2Router, SECRET_ROTATION_REGISTER_ROUTER_MAP } from "@app/ee/routes/v2/secret-rotation-v2-routers"; +import { + registerSecretScanningV2Router, + SECRET_SCANNING_REGISTER_ROUTER_MAP +} from "@app/ee/routes/v2/secret-scanning-v2-routers"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -31,4 +35,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { }, { prefix: "/secret-rotations" } ); + + await server.register( + async (secretScanningV2Router) => { + // register generic secret scanning endpoints + await secretScanningV2Router.register(registerSecretScanningV2Router); + + // register service-specific secret scanning endpoints (gitlab/github, etc.) + for await (const [type, router] of Object.entries(SECRET_SCANNING_REGISTER_ROUTER_MAP)) { + await secretScanningV2Router.register(router, { prefix: `data-sources/${type}` }); + } + }, + { prefix: "/secret-scanning" } + ); }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 90edc1306..c33609621 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -5,6 +5,7 @@ import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-ro import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; +import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; export * from "./secret-rotation-v2-router"; @@ -15,6 +16,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< > = { [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, + [SecretRotation.MySqlCredentials]: registerMySqlCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts new file mode 100644 index 000000000..99f02731c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mysql-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateMySqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, + UpdateMySqlCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/mysql-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerMySqlCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.MySqlCredentials, + server, + responseSchema: MySqlCredentialsRotationSchema, + createSchema: CreateMySqlCredentialsRotationSchema, + updateSchema: UpdateMySqlCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 298f2c412..5e3e09846 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -6,6 +6,7 @@ import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret- import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs"; @@ -16,6 +17,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, + MySqlCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, AzureClientSecretRotationListItemSchema, AwsIamUserSecretRotationListItemSchema, diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts new file mode 100644 index 000000000..3961e7cbd --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/github-secret-scanning-router.ts @@ -0,0 +1,16 @@ +import { registerSecretScanningEndpoints } from "@app/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints"; +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceSchema, + UpdateGitHubDataSourceSchema +} from "@app/ee/services/secret-scanning-v2/github"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export const registerGitHubSecretScanningRouter = async (server: FastifyZodProvider) => + registerSecretScanningEndpoints({ + type: SecretScanningDataSource.GitHub, + server, + responseSchema: GitHubDataSourceSchema, + createSchema: CreateGitHubDataSourceSchema, + updateSchema: UpdateGitHubDataSourceSchema + }); diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts new file mode 100644 index 000000000..703529947 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/index.ts @@ -0,0 +1,12 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +import { registerGitHubSecretScanningRouter } from "./github-secret-scanning-router"; + +export * from "./secret-scanning-v2-router"; + +export const SECRET_SCANNING_REGISTER_ROUTER_MAP: Record< + SecretScanningDataSource, + (server: FastifyZodProvider) => Promise +> = { + [SecretScanningDataSource.GitHub]: registerGitHubSecretScanningRouter +}; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts new file mode 100644 index 000000000..3a5c6b4d7 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-endpoints.ts @@ -0,0 +1,593 @@ +import { z } from "zod"; + +import { SecretScanningResourcesSchema, SecretScanningScansSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_NAME_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TSecretScanningDataSource, + TSecretScanningDataSourceInput +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { ApiDocsTags, SecretScanningDataSources } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +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"; + +export const registerSecretScanningEndpoints = < + T extends TSecretScanningDataSource, + I extends TSecretScanningDataSourceInput +>({ + server, + type, + createSchema, + updateSchema, + responseSchema +}: { + type: SecretScanningDataSource; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + connectionId?: string; + config: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + updateSchema: z.ZodType<{ + name?: string; + config?: Partial; + description?: string | null; + isAutoScanEnabled?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `List the ${sourceType} Data Sources for the specified project.`, + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.LIST(type).projectId) + }), + response: { + 200: z.object({ dataSources: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const dataSources = (await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId, type }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + type, + count: dataSources.length, + dataSourceIds: dataSources.map((source) => source.id) + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.GET_BY_ID(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceById( + { dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: `/data-source-name/:dataSourceName`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the specified ${sourceType} Data Source by name and project ID.`, + params: z.object({ + sourceName: z + .string() + .trim() + .min(1, "Data Source name required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).sourceName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.GET_BY_NAME(type).projectId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { sourceName } = req.params; + const { projectId } = req.query; + + const dataSource = (await server.services.secretScanningV2.findSecretScanningDataSourceByName( + { sourceName, projectId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET, + metadata: { + dataSourceId: dataSource.id, + type + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Create ${ + startsWithVowel(sourceType) ? "an" : "a" + } ${sourceType} Data Source for the specified project.`, + body: createSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const dataSource = (await server.services.secretScanningV2.createSecretScanningDataSource( + { ...req.body, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE, + metadata: { + dataSourceId: dataSource.id, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "PATCH", + url: "/:dataSourceId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Update the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.UPDATE(type).dataSourceId) + }), + body: updateSchema, + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.updateSecretScanningDataSource( + { ...req.body, dataSourceId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE, + metadata: { + dataSourceId, + type, + ...req.body + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "DELETE", + url: `/:dataSourceId`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Delete the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.DELETE(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.deleteSecretScanningDataSource( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "POST", + url: `/:dataSourceId/resources/:resourceId/scan`, + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Trigger a scan for the specified ${sourceType} Data Source resource.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).dataSourceId), + resourceId: z.string().uuid().describe(SecretScanningDataSources.SCAN(type).resourceId) + }), + response: { + 200: z.object({ dataSource: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId, resourceId } = req.params; + + const dataSource = (await server.services.secretScanningV2.triggerSecretScanningDataSourceScan( + { type, dataSourceId, resourceId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: dataSource.projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN, + metadata: { + type, + dataSourceId, + resourceId + } + } + }); + + return { dataSource }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/resources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the resources associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_RESOURCES(type).dataSourceId) + }), + response: { + 200: z.object({ resources: SecretScanningResourcesSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = await server.services.secretScanningV2.listSecretScanningResourcesByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: `Get the scans associated with the specified ${sourceType} Data Source by ID.`, + params: z.object({ + dataSourceId: z.string().uuid().describe(SecretScanningDataSources.LIST_SCANS(type).dataSourceId) + }), + response: { + 200: z.object({ scans: SecretScanningScansSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = await server.services.secretScanningV2.listSecretScanningScansByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/:dataSourceId/resources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + resources: SecretScanningResourcesSchema.extend({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { resources, projectId } = + await server.services.secretScanningV2.listSecretScanningResourcesWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_RESOURCE_LIST, + metadata: { + dataSourceId, + type, + resourceIds: resources.map((resource) => resource.id), + count: resources.length + } + } + }); + + return { resources }; + } + }); + + server.route({ + method: "GET", + url: "/:dataSourceId/scans-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + params: z.object({ + dataSourceId: z.string().uuid() + }), + response: { + 200: z.object({ + scans: SecretScanningScansSchema.extend({ + unresolvedFindings: z.number(), + resolvedFindings: z.number(), + resourceName: z.string() + }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { dataSourceId } = req.params; + + const { scans, projectId } = + await server.services.secretScanningV2.listSecretScanningScansWithDetailsByDataSourceId( + { dataSourceId, type }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_SCAN_LIST, + metadata: { + dataSourceId, + type, + count: scans.length + } + } + }); + + return { scans }; + } + }); +}; diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts new file mode 100644 index 000000000..70cfd08dc --- /dev/null +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -0,0 +1,366 @@ +import { z } from "zod"; + +import { SecretScanningConfigsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { GitHubDataSourceListItemSchema } from "@app/ee/services/secret-scanning-v2/github"; +import { + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + SecretScanningDataSourceSchema, + SecretScanningFindingSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas"; +import { + ApiDocsTags, + SecretScanningConfigs, + SecretScanningDataSources, + SecretScanningFindings +} from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const SecretScanningDataSourceOptionsSchema = z.discriminatedUnion("type", [GitHubDataSourceListItemSchema]); + +export const registerSecretScanningV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/data-sources/options", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List the available Secret Scanning Data Source Options.", + response: { + 200: z.object({ + dataSourceOptions: SecretScanningDataSourceOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const dataSourceOptions = server.services.secretScanningV2.listSecretScanningDataSourceOptions(); + return { dataSourceOptions }; + } + }); + + server.route({ + method: "GET", + url: "/data-sources", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Data Sources for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningDataSources.LIST().projectId) + }), + response: { + 200: z.object({ dataSources: SecretScanningDataSourceSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/findings", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "List all the Secret Scanning Findings for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ findings: SecretScanningFindingSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const findings = await server.services.secretScanningV2.listSecretScanningFindingsByProjectId( + projectId, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_LIST, + metadata: { + findingIds: findings.map((finding) => finding.id), + count: findings.length + } + } + }); + + return { findings }; + } + }); + + server.route({ + method: "PATCH", + url: "/findings/:findingId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Finding.", + params: z.object({ + findingId: z.string().trim().min(1, "Finding ID required").describe(SecretScanningFindings.UPDATE.findingId) + }), + body: z.object({ + status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), + remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) + }), + response: { + 200: z.object({ finding: SecretScanningFindingSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { findingId }, + body, + permission + } = req; + + const { finding, projectId } = await server.services.secretScanningV2.updateSecretScanningFindingById( + { findingId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_UPDATE, + metadata: { + findingId, + ...body + } + } + }); + + return { finding }; + } + }); + + server.route({ + method: "GET", + url: "/configs", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Get the Secret Scanning Config for the specified project.", + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningConfigs.GET_BY_PROJECT_ID.projectId) + }), + response: { + 200: z.object({ + config: z.object({ content: z.string().nullish(), projectId: z.string(), updatedAt: z.date().nullish() }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const config = await server.services.secretScanningV2.findSecretScanningConfigByProjectId(projectId, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_GET + } + }); + + return { config }; + } + }); + + server.route({ + method: "PATCH", + url: "/configs", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update the specified Secret Scanning Configuration.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningConfigs.UPDATE.projectId) + }), + body: z.object({ + content: z.string().nullable().describe(SecretScanningConfigs.UPDATE.content) + }), + response: { + 200: z.object({ config: SecretScanningConfigsSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + body, + permission + } = req; + + const config = await server.services.secretScanningV2.upsertSecretScanningConfig( + { projectId, ...body }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE, + metadata: body + } + }); + + return { config }; + } + }); + + // not exposed, for UI only + server.route({ + method: "GET", + url: "/data-sources-dashboard", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: z.object({ + dataSources: z + .intersection( + SecretScanningDataSourceSchema, + z.object({ + lastScannedAt: z.date().nullish(), + lastScanStatus: z.nativeEnum(SecretScanningScanStatus).nullish(), + lastScanStatusMessage: z.string().nullish(), + unresolvedFindings: z.number().nullish() + }) + ) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const dataSources = await server.services.secretScanningV2.listSecretScanningDataSourcesWithDetailsByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST, + metadata: { + dataSourceIds: dataSources.map((dataSource) => dataSource.id), + count: dataSources.length + } + } + }); + + return { dataSources }; + } + }); + + server.route({ + method: "GET", + url: "/unresolved-findings-count", + config: { + rateLimit: readLimit + }, + schema: { + tags: [ApiDocsTags.SecretScanning], + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretScanningFindings.LIST.projectId) + }), + response: { + 200: z.object({ unresolvedFindings: z.number() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const unresolvedFindings = + await server.services.secretScanningV2.getSecretScanningUnresolvedFindingsCountByProjectId( + projectId, + permission + ); + + return { unresolvedFindings }; + } + }); +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts index e14854d8f..c141c762b 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); return { ...accessApprovalPolicyApproverOrm }; }; + +export type TAccessApprovalPolicyBypasserDALFactory = ReturnType; + +export const accessApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const accessApprovalPolicyBypasserOrm = ormify(db, TableName.AccessApprovalPolicyBypasser); + return { ...accessApprovalPolicyBypasserOrm }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index e14451498..c61d209c3 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -1,11 +1,11 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "./access-approval-policy-types"; +import { ApproverType, BypasserType } from "./access-approval-policy-types"; export type TAccessApprovalPolicyDALFactory = ReturnType; @@ -34,9 +34,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.policyId` ) .leftJoin(TableName.Users, `${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("bypasserUsers"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + `bypasserUsers.id` + ) .select(tx.ref("username").withSchema(TableName.Users).as("approverUsername")) + .select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername")) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) @@ -129,6 +142,23 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { id, type: ApproverType.Group }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + id, + type: BypasserType.User, + name: bypasserUsername + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + id, + type: BypasserType.Group + }) } ] }); @@ -144,5 +174,28 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { return softDeletedPolicy; }; - return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; + const findLastValidPolicy = async ({ envId, secretPath }: { envId: string; secretPath: string }, tx?: Knex) => { + try { + const result = await (tx || db.replicaNode())(TableName.AccessApprovalPolicy) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + envId, + secretPath + }, + TableName.AccessApprovalPolicy + ) + ) + .orderBy("deletedAt", "desc") + .orderByRaw(`"deletedAt" IS NULL`) + .first(); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "FindLastValidPolicy" }); + } + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById, findLastValidPolicy }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 17176162b..71d15ce1c 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -2,8 +2,9 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,10 +15,14 @@ import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-req import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { + TAccessApprovalPolicyApproverDALFactory, + TAccessApprovalPolicyBypasserDALFactory +} from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { ApproverType, + BypasserType, TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, TGetAccessApprovalPolicyByIdDTO, @@ -32,12 +37,14 @@ type TAccessApprovalPolicyServiceFactoryDep = { accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; + orgMembershipDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -45,6 +52,7 @@ export type TAccessApprovalPolicyServiceFactory = ReturnType { const createAccessApprovalPolicy = async ({ name, @@ -63,6 +72,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectSlug, environment, enforcementLevel, @@ -82,7 +92,7 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) @@ -98,7 +108,7 @@ export const accessApprovalPolicyServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Create, + ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); @@ -147,6 +157,44 @@ export const accessApprovalPolicyServiceFactory = ({ .map((user) => user.id); verifyAllApprovers.push(...verifyGroupApprovers); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -159,6 +207,7 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); + if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( approverUserIds.map((userId) => ({ @@ -179,8 +228,29 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); + return { ...accessApproval, environment: env, projectId: project.id }; }; @@ -211,6 +281,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, approvers, + bypassers, secretPath, name, actorId, @@ -231,15 +302,15 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); - const currentAppovals = approvals || accessApprovalPolicy.approvals; + const currentApprovals = approvals || accessApprovalPolicy.approvals; if ( groupApprovers?.length === 0 && userApprovers && - currentAppovals > userApprovers.length + userApproverNames.length + currentApprovals > userApprovers.length + userApproverNames.length ) { throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } @@ -256,10 +327,79 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Edit, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + groupBypassers = [...new Set(groupBypassers)]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))]; + } + + // Validate user bypassers + if (bypasserUserIds.length > 0) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { userId: bypasserUserIds }, + orgId: actorOrgId + }); + + if (orgMemberships.length !== bypasserUserIds.length) { + const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.userId)); + const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}` + }); + } + } + + // Validate group bypassers + if (groupBypassers.length > 0) { + const orgGroups = await groupDAL.find({ + $in: { id: groupBypassers }, + orgId: actorOrgId + }); + + if (orgGroups.length !== groupBypassers.length) { + const foundGroupIdsInOrg = new Set(orgGroups.map((group) => group.id)); + const missingGroupIds = groupBypassers.filter((id) => !foundGroupIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser groups are not part of the organization or do not exist. Invalid or non-member group IDs: ${missingGroupIds.join(", ")}` + }); + } + } + } const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( @@ -316,6 +456,28 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { @@ -344,7 +506,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Delete, + ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -435,10 +597,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); return policy; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index dde8ffbea..cef7f68f4 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -18,11 +18,20 @@ export enum ApproverType { User = "user" } +export enum BypasserType { + Group = "group", + User = "user" +} + export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -32,7 +41,11 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index e2075af0a..bfd07bdcf 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -1,7 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, TUsers } from "@app/db/schemas"; +import { + AccessApprovalRequestsSchema, + TableName, + TAccessApprovalRequests, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; @@ -28,12 +34,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalRequest}.policyId`, `${TableName.AccessApprovalPolicy}.id` ) - .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequestReviewer}.requestId` ) + .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, @@ -46,6 +52,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .join( db(TableName.Users).as("requestedByUser"), `${TableName.AccessApprovalRequest}.requestedByUserId`, @@ -69,6 +86,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId")) + .select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId")) + .select( db.ref("projectId").withSchema(TableName.Environment), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -145,7 +165,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.policyDeletedAt || !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId || doc.status !== ApprovalStatus.PENDING }), childrenMapper: [ { @@ -158,6 +178,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => approverGroupUserId + }, + { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => bypasserGroupUserId } ] }); @@ -166,7 +192,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { return formattedDocs.map((doc) => ({ ...doc, - policy: { ...doc.policy, approvers: doc.approvers } + policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); @@ -193,7 +219,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyApproverUser"), `${TableName.AccessApprovalPolicyApprover}.approverUserId`, @@ -204,13 +229,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyGroupApproverUser"), `${TableName.UserGroupMembership}.userId`, "accessApprovalPolicyGroupApproverUser.id" ) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyBypasserUser"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + "accessApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + "accessApprovalPolicyGroupBypasserUser.id" + ) + .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, @@ -241,6 +286,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), tx.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), + // Bypassers + tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("accessApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("accessApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer), tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), @@ -265,7 +322,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -335,13 +392,51 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId: bypasserUserId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); @@ -392,14 +487,20 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ] }); - // an approval is pending if there is no reviewer rejections and no privilege ID is set + // an approval is pending if there is no reviewer rejections, no privilege ID is set and the status is pending const pendingApprovals = formattedRequests.filter( - (req) => !req.privilegeId && !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + (req) => + !req.privilegeId && + !req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) && + req.status === ApprovalStatus.PENDING ); - // an approval is finalized if there are any rejections or a privilege ID is set + // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required const finalizedApprovals = formattedRequests.filter( - (req) => req.privilegeId || req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) + (req) => + req.privilegeId || + req.reviewers.some((r) => r.status === ApprovalStatus.REJECTED) || + req.status !== ApprovalStatus.PENDING ); return { pendingCount: pendingApprovals.length, finalizedCount: finalizedApprovals.length }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 017356a5d..d03cc64c0 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -23,7 +23,6 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; @@ -57,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = { | "findOne" | "getCount" >; - accessApprovalPolicyDAL: Pick; + accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, "create" | "find" | "findOne" | "transaction" @@ -132,7 +131,7 @@ export const accessApprovalRequestServiceFactory = ({ if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policy = await accessApprovalPolicyDAL.findOne({ + const policy = await accessApprovalPolicyDAL.findLastValidPolicy({ envId: environment.id, secretPath }); @@ -204,7 +203,7 @@ export const accessApprovalRequestServiceFactory = ({ const isRejected = reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED); - if (!isRejected) { + if (!isRejected && duplicateRequest.status === ApprovalStatus.PENDING) { throw new BadRequestError({ message: "You already have a pending access request with the same criteria" }); } } @@ -340,7 +339,7 @@ export const accessApprovalRequestServiceFactory = ({ }); } - const { membership, hasRole, permission } = await permissionService.getProjectPermission({ + const { membership, hasRole } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -355,13 +354,13 @@ export const accessApprovalRequestServiceFactory = ({ const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; - const canBypassApproval = permission.can( - ProjectPermissionApprovalActions.AllowAccessBypass, - ProjectPermissionSub.SecretApproval - ); - const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypassApproval); + const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass); - if (!policy.allowedSelfApprovals && isSelfApproval && cannotBypassUnderSoftEnforcement) { + const isApprover = policy.approvers.find((approver) => approver.userId === actorId); + + // If user is (not an approver OR cant self approve) AND can't bypass policy + if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) { throw new BadRequestError({ message: "Failed to review access approval request. Users are not authorized to review their own request." }); @@ -370,7 +369,7 @@ export const accessApprovalRequestServiceFactory = ({ if ( !hasRole(ProjectMembershipRole.Admin) && accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user - !policy.approvers.find((approver) => approver.userId === actorId) // The request isn't performed by an assigned approver + !isApprover // The request isn't performed by an assigned approver ) { throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); } @@ -478,7 +477,11 @@ export const accessApprovalRequestServiceFactory = ({ ); privilegeIdToSet = privilege.id; } - await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId: privilegeIdToSet }, tx); + await accessApprovalRequestDAL.updateById( + accessApprovalRequest.id, + { privilegeId: privilegeIdToSet, status: ApprovalStatus.APPROVED }, + tx + ); } } 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 a8956845d..5fdcb7be4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -10,6 +10,18 @@ import { TSecretRotationV2Raw, TUpdateSecretRotationV2DTO } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { + SecretScanningDataSource, + SecretScanningScanStatus, + SecretScanningScanType +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; @@ -388,6 +400,19 @@ export enum EventType { PIT_REVERT_COMMIT = "pit-revert-commit", PIT_GET_FOLDER_STATE = "pit-get-folder-state", PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states", + SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list", + SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create", + SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update", + SECRET_SCANNING_DATA_SOURCE_DELETE = "secret-scanning-data-source-delete", + SECRET_SCANNING_DATA_SOURCE_GET = "secret-scanning-data-source-get", + SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN = "secret-scanning-data-source-trigger-scan", + SECRET_SCANNING_DATA_SOURCE_SCAN = "secret-scanning-data-source-scan", + SECRET_SCANNING_RESOURCE_LIST = "secret-scanning-resource-list", + SECRET_SCANNING_SCAN_LIST = "secret-scanning-scan-list", + SECRET_SCANNING_FINDING_LIST = "secret-scanning-finding-list", + SECRET_SCANNING_FINDING_UPDATE = "secret-scanning-finding-update", + SECRET_SCANNING_CONFIG_GET = "secret-scanning-config-get", + SECRET_SCANNING_CONFIG_UPDATE = "secret-scanning-config-update", UPDATE_ORG = "update-org", @@ -3033,6 +3058,101 @@ interface PitCompareFolderStatesEvent { }; } +interface SecretScanningDataSourceListEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; + metadata: { + type?: SecretScanningDataSource; + count: number; + dataSourceIds: string[]; + }; +} + +interface SecretScanningDataSourceGetEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_GET; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + }; +} + +interface SecretScanningDataSourceCreateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_CREATE; + metadata: Omit & { dataSourceId: string }; +} + +interface SecretScanningDataSourceUpdateEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_UPDATE; + metadata: TUpdateSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceDeleteEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_DELETE; + metadata: TDeleteSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceTriggerScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_TRIGGER_SCAN; + metadata: TTriggerSecretScanningDataSourceDTO; +} + +interface SecretScanningDataSourceScanEvent { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN; + metadata: { + scanId: string; + resourceId: string; + resourceType: string; + dataSourceId: string; + dataSourceType: string; + scanStatus: SecretScanningScanStatus; + scanType: SecretScanningScanType; + numberOfSecretsDetected?: number; + }; +} + +interface SecretScanningResourceListEvent { + type: EventType.SECRET_SCANNING_RESOURCE_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + resourceIds: string[]; + count: number; + }; +} + +interface SecretScanningScanListEvent { + type: EventType.SECRET_SCANNING_SCAN_LIST; + metadata: { + type: SecretScanningDataSource; + dataSourceId: string; + count: number; + }; +} + +interface SecretScanningFindingListEvent { + type: EventType.SECRET_SCANNING_FINDING_LIST; + metadata: { + findingIds: string[]; + count: number; + }; +} + +interface SecretScanningFindingUpdateEvent { + type: EventType.SECRET_SCANNING_FINDING_UPDATE; + metadata: TUpdateSecretScanningFindingDTO; +} + +interface SecretScanningConfigUpdateEvent { + type: EventType.SECRET_SCANNING_CONFIG_UPDATE; + metadata: { + content: string | null; + }; +} + +interface SecretScanningConfigReadEvent { + type: EventType.SECRET_SCANNING_CONFIG_GET; + metadata?: Record; // not needed, based off projectId +} + interface OrgUpdateEvent { type: EventType.UPDATE_ORG; metadata: { @@ -3363,6 +3483,19 @@ export type Event = | PitRevertCommitEvent | PitCompareFolderStatesEvent | PitGetFolderStateEvent + | SecretScanningDataSourceListEvent + | SecretScanningDataSourceGetEvent + | SecretScanningDataSourceCreateEvent + | SecretScanningDataSourceUpdateEvent + | SecretScanningDataSourceDeleteEvent + | SecretScanningDataSourceTriggerScanEvent + | SecretScanningDataSourceScanEvent + | SecretScanningResourceListEvent + | SecretScanningScanListEvent + | SecretScanningFindingListEvent + | SecretScanningFindingUpdateEvent + | SecretScanningConfigUpdateEvent + | SecretScanningConfigReadEvent | OrgUpdateEvent | ProjectCreateEvent | ProjectUpdateEvent diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 4adf8b7e2..f3f3f3acd 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -132,7 +132,11 @@ export const dynamicSecretLeaseServiceFactory = ({ let result; try { - result = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + result = await selectedProvider.create({ + inputs: decryptedStoredInput, + expireAt: expireAt.getTime(), + usernameTemplate: dynamicSecretCfg.usernameTemplate + }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { throw new BadRequestError({ message: error.sqlMessage as string }); diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index f653d0c0c..3b405a418 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -11,6 +11,8 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = if (appCfg.isDevelopmentMode) return [host]; + if (isGateway) return [host]; + const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), getDbConnectionHost(appCfg.REDIS_URL), @@ -58,7 +60,7 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = } } - if (!isGateway && !(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { + if (!(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) { const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" }); } diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index c39f07b5c..16ac10716 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -78,7 +78,8 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, defaultTTL, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TCreateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -163,7 +164,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); @@ -199,7 +201,8 @@ export const dynamicSecretServiceFactory = ({ newName, actorOrgId, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TUpdateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -311,7 +314,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 58fdc2143..6720cf2c8 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -22,6 +22,7 @@ export type TCreateDynamicSecretDTO = { name: string; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TUpdateDynamicSecretDTO = { @@ -34,6 +35,7 @@ export type TUpdateDynamicSecretDTO = { inputs?: TProvider["inputs"]; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TDeleteDynamicSecretDTO = { diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index f2907f7dc..56fa110d1 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -132,9 +132,15 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { +const generateUsername = (usernameTemplate?: string | null) => { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; - return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + const randomUsername = `inf-${customAlphabet(charset, 32)()}`; + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { @@ -168,13 +174,14 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); if (!(await validateConnection(providerInputs))) { throw new BadRequestError({ message: "Failed to establish connection" }); } - const leaseUsername = generateUsername(); + const leaseUsername = generateUsername(usernameTemplate); const leasePassword = generatePassword(); const leaseExpiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index 64ea6a02e..9d8e10f60 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,6 +16,7 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; +import handlebars from "handlebars"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; @@ -23,8 +24,14 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsIamProvider = (): TDynamicProviderFns => { @@ -53,11 +60,13 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; const createUserRes = await client.send( new CreateUserCommand({ diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 17f644601..4b2232bc8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -55,7 +55,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return data.success; }; - const create = async (inputs: unknown) => { + const create = async ({ inputs }: { inputs: unknown }) => { const providerInputs = await validateProviderInputs(inputs); const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); if (!data.success) { @@ -88,7 +88,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { const revoke = async (inputs: unknown, entityId: string) => { // Creates a new password - await create(inputs); + await create({ inputs }); return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index 0b6d50146..fce23b56f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -14,8 +14,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const CassandraProvider = (): TDynamicProviderFns => { @@ -69,11 +75,12 @@ export const CassandraProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const { keyspace } = providerInputs; const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 6c1affa39..066822827 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -1,4 +1,5 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const ElasticSearchProvider = (): TDynamicProviderFns => { @@ -64,11 +71,12 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); await connection.security.putUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 737aaadea..76ef7ef2a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -6,6 +6,7 @@ import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; +import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; @@ -16,6 +17,7 @@ import { SapAseProvider } from "./sap-ase"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; +import { VerticaProvider } from "./vertica"; type TBuildDynamicSecretProviderDTO = { gatewayService: Pick; @@ -38,5 +40,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), - [DynamicSecretProviders.SapAse]: SapAseProvider() + [DynamicSecretProviders.SapAse]: SapAseProvider(), + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), + [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }) }); diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts new file mode 100644 index 000000000..130e0fa92 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -0,0 +1,200 @@ +import axios from "axios"; +import https from "https"; + +import { InternalServerError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { DynamicSecretKubernetesSchema, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +type TKubernetesProviderDTO = { + gatewayService: Pick; +}; + +export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); + if (!providerInputs.gatewayId) { + await blockLocalAndPrivateIpAddresses(providerInputs.url); + } + + return providerInputs; + }; + + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost: string; + targetPort: number; + }, + gatewayCallback: (host: string, port: number) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port) => { + // Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server" + const res = await gatewayCallback("https://localhost", port); + return res; + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountGetCallback = async (host: string, port: number) => { + const baseUrl = port ? `${host}:${port}` : host; + + await axios.get( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + }; + + const url = new URL(providerInputs.url); + const k8sPort = url.port ? Number(url.port) : 443; + + try { + if (providerInputs.gatewayId) { + const k8sHost = url.hostname; + + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort + }, + serviceAccountGetCallback + ); + } else { + const k8sHost = `${url.protocol}//${url.hostname}`; + await serviceAccountGetCallback(k8sHost, k8sPort); + } + + return true; + } catch (error) { + let errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { + errorMessage = (error.response?.data as { message: string }).message; + } + + throw new InternalServerError({ + message: `Failed to validate connection: ${errorMessage}` + }); + } + }; + + const create = async ({ inputs, expireAt }: { inputs: unknown; expireAt: number }) => { + const providerInputs = await validateProviderInputs(inputs); + + const tokenRequestCallback = async (host: string, port: number) => { + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${providerInputs.serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${providerInputs.clusterToken}` + }, + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT, + httpsAgent: new https.Agent({ + ca: providerInputs.ca, + rejectUnauthorized: providerInputs.sslEnabled + }) + } + ); + + return res.data; + }; + + const url = new URL(providerInputs.url); + const k8sHost = `${url.protocol}//${url.hostname}`; + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + + try { + const tokenData = providerInputs.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort + }, + tokenRequestCallback + ) + : await tokenRequestCallback(k8sHost, k8sPort); + + return { + entityId: providerInputs.serviceAccountName, + data: { TOKEN: tokenData.status.token } + }; + } catch (error) { + let errorMessage = error instanceof Error ? error.message : "Unknown error"; + if (axios.isAxiosError(error) && (error.response?.data as { message: string })?.message) { + errorMessage = (error.response?.data as { message: string }).message; + } + + throw new InternalServerError({ + message: `Failed to create dynamic secret: ${errorMessage}` + }); + } + }; + + const revoke = async (_inputs: unknown, entityId: string) => { + return { entityId }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // No renewal necessary + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index cc68304e0..d0e3fbe66 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -22,8 +22,14 @@ const encodePassword = (password?: string) => { return base64Password; }; -const generateUsername = () => { - return alphaNumericNanoId(20); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const generateLDIF = ({ @@ -190,7 +196,8 @@ export const LdapProvider = (): TDynamicProviderFns => { return dnArray; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); @@ -217,7 +224,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }); } } else { - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const generatedLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.creationLdif }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 0c6eaf151..91d26da32 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -16,7 +16,8 @@ export enum SqlProviders { MySQL = "mysql2", Oracle = "oracledb", MsSQL = "mssql", - SapAse = "sap-ase" + SapAse = "sap-ase", + Vertica = "vertica" } export enum ElasticSearchAuthTypes { @@ -29,6 +30,10 @@ export enum LdapCredentialType { Static = "static" } +export enum KubernetesCredentialType { + Static = "static" +} + export enum TotpConfigType { URL = "url", MANUAL = "manual" @@ -277,6 +282,51 @@ export const LdapSchema = z.union([ }) ]); +export const DynamicSecretKubernetesSchema = z.object({ + url: z.string().url().trim().min(1), + gatewayId: z.string().nullable().optional(), + sslEnabled: z.boolean().default(true), + clusterToken: z.string().trim().min(1), + ca: z.string().optional(), + serviceAccountName: z.string().trim().min(1), + credentialType: z.literal(KubernetesCredentialType.Static), + namespace: z.string().trim().min(1), + audiences: z.array(z.string().trim().min(1)) +}); + +export const DynamicSecretVerticaSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), + password: z.string().trim(), + database: z.string().trim(), + gatewayId: z.string().nullable().optional(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements") +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -320,7 +370,9 @@ export enum DynamicSecretProviders { SapHana = "sap-hana", Snowflake = "snowflake", Totp = "totp", - SapAse = "sap-ase" + SapAse = "sap-ase", + Kubernetes = "kubernetes", + Vertica = "vertica" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -338,11 +390,17 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }) ]); export type TDynamicProviderFns = { - create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + create: (arg: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + }) => Promise<{ entityId: string; data: unknown }>; validateConnection: (inputs: unknown) => Promise; validateProviderInputs: (inputs: object) => Promise; revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 6cb414d10..8f8bf9430 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from "axios"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const MongoAtlasProvider = (): TDynamicProviderFns => { @@ -57,11 +64,12 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); await client({ diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index bee29bfc4..0a15209e0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -1,3 +1,4 @@ +import handlebars from "handlebars"; import { MongoClient } from "mongodb"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const MongoDBProvider = (): TDynamicProviderFns => { @@ -53,11 +60,12 @@ export const MongoDBProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const db = client.db(providerInputs.database); diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index f6c73ba54..e7d90d272 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -1,4 +1,5 @@ import axios, { Axios } from "axios"; +import handlebars from "handlebars"; import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -14,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; type TCreateRabbitMQUser = { @@ -110,11 +117,12 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); await createRabbitMqUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index f180dd607..855af2e29 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -15,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const executeTransactions = async (connection: Redis, commands: string[]): Promise<(string | null)[] | null> => { @@ -115,11 +121,12 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return pingResponse; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index c832e9867..af2431058 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -15,8 +15,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(25); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; enum SapCommands { @@ -81,11 +87,12 @@ export const SapAseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = `inf_${generateUsername()}`; - const password = `${generatePassword()}`; + const username = generateUsername(usernameTemplate); + const password = generatePassword(); const client = await $getClient(providerInputs); const masterClient = await $getClient(providerInputs, true); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index 1ad24473c..654e2d144 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -21,8 +21,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const SapHanaProvider = (): TDynamicProviderFns => { @@ -91,10 +97,11 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return testResult; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index bea7eca89..571d488c9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -17,8 +17,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return `infisical_${alphaNumericNanoId(32)}`; // username must start with alpha character, hence prefix +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `infisical_${alphaNumericNanoId(32)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const getDaysToExpiry = (expiryDate: Date) => { @@ -82,12 +88,13 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return isValidConnection; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); try { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 3ae85ed7b..39f8dd6de 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,7 +3,7 @@ import handlebars from "handlebars"; import knex from "knex"; import { z } from "zod"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -104,11 +104,21 @@ const generatePassword = (provider: SqlProviders, requirements?: PasswordRequire } }; -const generateUsername = (provider: SqlProviders) => { - // For oracle, the client assumes everything is upper case when not using quotes around the password - if (provider === SqlProviders.Oracle) return alphaNumericNanoId(32).toUpperCase(); +const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null) => { + let randomUsername = ""; - return alphaNumericNanoId(32); + // For oracle, the client assumes everything is upper case when not using quotes around the password + if (provider === SqlProviders.Oracle) { + randomUsername = alphaNumericNanoId(32).toUpperCase(); + } else { + randomUsername = alphaNumericNanoId(32); + } + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; type TSqlDatabaseProviderDTO = { @@ -175,6 +185,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await gatewayCallback("localhost", port); }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: providerInputs.host, targetPort: providerInputs.port, relayHost, @@ -210,9 +221,12 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(providerInputs.client); + const username = generateUsername(providerInputs.client, usernameTemplate); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts new file mode 100644 index 000000000..e361ab329 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -0,0 +1,368 @@ +import { randomInt } from "crypto"; +import handlebars from "handlebars"; +import knex, { Knex } from "knex"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +interface VersionResult { + version: string; +} + +interface SessionResult { + session_id?: string; +} + +interface DatabaseQueryResult { + rows?: Array>; +} + +// Extended Knex client interface to handle Vertica-specific overrides +interface VerticaKnexClient extends Knex { + client: { + parseVersion?: () => string; + }; +} + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + +type TVerticaProviderDTO = { + gatewayService: Pick; +}; + +export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretVerticaSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("Vertica revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } + return { ...providerInputs, hostIp }; + }; + + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const config = { + client: "pg", + connection: { + host: providerInputs.hostIp, + port: providerInputs.port, + database: providerInputs.database, + user: providerInputs.username, + password: providerInputs.password, + ssl: false + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { + min: 0, + max: 1, + acquireTimeoutMillis: 30000, + createTimeoutMillis: 30000, + destroyTimeoutMillis: 5000, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 100 + }, + // Disable version checking for Vertica compatibility + version: "9.6.0" // Fake a compatible PostgreSQL version + }; + + const client = knex(config) as VerticaKnexClient; + + // Override the version parsing to prevent errors with Vertica + if (client.client && typeof client.client.parseVersion !== "undefined") { + client.client.parseVersion = () => "9.6.0"; + } + + return client; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + + const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); + + const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined; + + if (!resultFromSelectedDatabase?.version) { + throw new BadRequestError({ + message: "Failed to validate Vertica connection, version query failed" + }); + } + + isConnected = true; + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return isConnected; + }; + + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); + + const username = generateUsername(usernameTemplate); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Execute queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Check for active sessions and close them + try { + const sessionResult: DatabaseQueryResult = await client.raw( + "SELECT session_id FROM sessions WHERE user_name = ?", + [username] + ); + + const activeSessions = (sessionResult.rows || []) as SessionResult[]; + + // Close all sessions in parallel since they're independent operations + if (activeSessions.length > 0) { + const sessionClosePromises = activeSessions.map(async (session) => { + try { + await client!.raw("SELECT close_session(?)", [session.session_id]); + } catch (error) { + // Continue if session is already closed + logger.error(error, `Failed to close session ${session.session_id}`); + } + }); + + await Promise.allSettled(sessionClosePromises); + } + } catch (error) { + // Continue if we can't query sessions (permissions, etc.) + logger.error(error, "Could not query/close active sessions"); + } + + // Execute revocation queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 231a35d0d..c2db3e6e7 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ kmip: false, gateway: false, sshHostGroups: false, + secretScanning: false, enterpriseSecretSyncs: false, enterpriseAppConnections: false }); diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index cfc42d038..e2cf09bb1 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -17,7 +17,7 @@ import { TIdentityOrgDALFactory } from "@app/services/identity/identity-org-dal" import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionBillingActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums"; import { TLicenseDALFactory } from "./license-dal"; @@ -288,7 +288,7 @@ export const licenseServiceFactory = ({ billingCycle }: TOrgPlansTableDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` ); @@ -310,8 +310,10 @@ export const licenseServiceFactory = ({ success_url }: TStartOrgTrialDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -338,8 +340,10 @@ export const licenseServiceFactory = ({ actorOrgId }: TCreateOrgPortalSession) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -385,7 +389,7 @@ export const licenseServiceFactory = ({ const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -413,7 +417,7 @@ export const licenseServiceFactory = ({ // returns org current plan feature table const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -484,7 +488,7 @@ export const licenseServiceFactory = ({ const getOrgBillingDetails = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -509,7 +513,10 @@ export const licenseServiceFactory = ({ email }: TUpdateOrgBillingDetailsDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -529,7 +536,7 @@ export const licenseServiceFactory = ({ const getOrgPmtMethods = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPmtMethodsDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -556,7 +563,10 @@ export const licenseServiceFactory = ({ cancel_url }: TAddOrgPmtMethodDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -585,7 +595,10 @@ export const licenseServiceFactory = ({ pmtMethodId }: TDelOrgPmtMethodDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -602,7 +615,7 @@ export const licenseServiceFactory = ({ const getOrgTaxIds = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -620,7 +633,10 @@ export const licenseServiceFactory = ({ const addOrgTaxId = async ({ actorId, actor, actorAuthMethod, actorOrgId, orgId, type, value }: TAddOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -641,7 +657,10 @@ export const licenseServiceFactory = ({ const delOrgTaxId = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId, taxId }: TDelOrgTaxIdDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionBillingActions.ManageBilling, + OrgPermissionSubjects.Billing + ); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -658,7 +677,7 @@ export const licenseServiceFactory = ({ const getOrgTaxInvoices = async ({ actorId, actor, actorOrgId, actorAuthMethod, orgId }: TOrgInvoiceDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { @@ -675,7 +694,7 @@ export const licenseServiceFactory = ({ const getOrgLicenses = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgLicensesDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); const organization = await orgDAL.findOrgById(orgId); if (!organization) { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index f509c7127..2937ac265 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -72,6 +72,7 @@ export type TFeatureSet = { kmip: false; gateway: false; sshHostGroups: false; + secretScanning: false; enterpriseSecretSyncs: false; enterpriseAppConnections: false; }; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index c59c05552..d933835e4 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -44,7 +44,6 @@ import { TOidcLoginDTO, TUpdateOidcCfgDTO } from "./oidc-config-types"; -import { logger } from "@app/lib/logger"; type TOidcConfigServiceFactoryDep = { userDAL: Pick< @@ -700,7 +699,6 @@ export const oidcConfigServiceFactory = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (_req: any, tokenSet: TokenSet, cb: any) => { const claims = tokenSet.claims(); - logger.info(`User OIDC claims received for [orgId=${org.id}] [claims=${JSON.stringify(claims)}]`); if (!claims.email || !claims.given_name) { throw new BadRequestError({ message: "Invalid request. Missing email or first name" diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index f7ca2abcf..cca4efaf2 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,7 +2,6 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, - ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -12,8 +11,12 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretRotationActions, + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, ProjectPermissionSecretSyncActions, ProjectPermissionSet, ProjectPermissionSshHostActions, @@ -37,7 +40,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, - ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, @@ -58,12 +60,22 @@ const buildAdminPermissionRules = () => { can( [ - ProjectPermissionApprovalActions.Read, - ProjectPermissionApprovalActions.Edit, - ProjectPermissionApprovalActions.Create, - ProjectPermissionApprovalActions.Delete, - ProjectPermissionApprovalActions.AllowChangeBypass, - ProjectPermissionApprovalActions.AllowAccessBypass + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionPkiTemplateActions.IssueCert, + ProjectPermissionPkiTemplateActions.ListCerts + ], + ProjectPermissionSub.CertificateTemplates + ); + + can( + [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete ], ProjectPermissionSub.SecretApproval ); @@ -145,6 +157,7 @@ const buildAdminPermissionRules = () => { can( [ ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, @@ -216,6 +229,29 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretRotation ); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can( + [ProjectPermissionSecretScanningConfigActions.Read, ProjectPermissionSecretScanningConfigActions.Update], + ProjectPermissionSub.SecretScanningConfigs + ); + return rules; }; @@ -225,6 +261,7 @@ const buildMemberPermissionRules = () => { can( [ ProjectPermissionSecretActions.DescribeSecret, + ProjectPermissionSecretActions.DescribeAndReadValue, ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, @@ -266,7 +303,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Commits ); - can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -362,7 +399,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); @@ -401,6 +438,23 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can( + [ProjectPermissionSecretScanningFindingActions.Read, ProjectPermissionSecretScanningFindingActions.Update], + ProjectPermissionSub.SecretScanningFindings + ); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + return rules; }; @@ -414,7 +468,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); - can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); @@ -431,12 +485,26 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + can( + [ + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSecretScanningDataSourceActions.ReadResources + ], + ProjectPermissionSub.SecretScanningDataSources + ); + + can([ProjectPermissionSecretScanningFindingActions.Read], ProjectPermissionSub.SecretScanningFindings); + + can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + return rules; }; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 612914bcc..f0fe73d71 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -67,6 +67,11 @@ export enum OrgPermissionGroupActions { RemoveMembers = "remove-members" } +export enum OrgPermissionBillingActions { + Read = "read", + ManageBilling = "manage-billing" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -107,7 +112,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgPermissionActions, OrgPermissionSubjects.Billing] + | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] @@ -298,10 +303,8 @@ const buildAdminPermission = () => { can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing); can(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); @@ -362,7 +365,7 @@ const buildMemberPermission = () => { can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); - can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 761e8eddd..14d515817 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -39,15 +39,6 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } -export enum ProjectPermissionApprovalActions { - Read = "read", - Create = "create", - Edit = "edit", - Delete = "delete", - AllowChangeBypass = "allow-change-bypass", - AllowAccessBypass = "allow-access-bypass" -} - export enum ProjectPermissionCmekActions { Read = "read", Create = "create", @@ -101,6 +92,15 @@ export enum ProjectPermissionSshHostActions { IssueHostCert = "issue-host-cert" } +export enum ProjectPermissionPkiTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert", + ListCerts = "list-certs" +} + export enum ProjectPermissionPkiSubscriberActions { Read = "read", Create = "create", @@ -137,6 +137,26 @@ export enum ProjectPermissionKmipActions { GenerateClientCertificates = "generate-client-certificates" } +export enum ProjectPermissionSecretScanningDataSourceActions { + Read = "read-data-sources", + Create = "create-data-sources", + Edit = "edit-data-sources", + Delete = "delete-data-sources", + TriggerScans = "trigger-data-source-scans", + ReadScans = "read-data-source-scans", + ReadResources = "read-data-source-resources" +} + +export enum ProjectPermissionSecretScanningFindingActions { + Read = "read-findings", + Update = "update-findings" +} + +export enum ProjectPermissionSecretScanningConfigActions { + Read = "read-configs", + Update = "update-configs" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -173,7 +193,10 @@ export enum ProjectPermissionSub { Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", - Kmip = "kmip" + Kmip = "kmip", + SecretScanningDataSources = "secret-scanning-data-sources", + SecretScanningFindings = "secret-scanning-findings", + SecretScanningConfigs = "secret-scanning-configs" } export type SecretSubjectFields = { @@ -215,6 +238,11 @@ export type SshHostSubjectFields = { hostname: string; }; +export type PkiTemplateSubjectFields = { + name: string; + // (dangtony98): consider adding [commonName] as a subject field in the future +}; + export type PkiSubscriberSubjectFields = { name: string; // (dangtony98): consider adding [commonName] as a subject field in the future @@ -257,7 +285,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionSecretRotationActions, ( @@ -271,7 +299,13 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] - | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ + ProjectPermissionPkiTemplateActions, + ( + | ProjectPermissionSub.CertificateTemplates + | (ForcedSubject & PkiTemplateSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] @@ -297,7 +331,10 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] - | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]; + | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] + | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] + | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -452,10 +489,25 @@ const PkiSubscriberConditionSchema = z }) .partial(); +const PkiTemplateConditionSchema = z + .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ) }), @@ -543,12 +595,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z .literal(ProjectPermissionSub.SshCertificateAuthorities) @@ -624,6 +670,26 @@ const GeneralPermissionSchema = [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCommitsActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SecretScanningDataSources) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningDataSourceActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningFindings).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningFindingActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretScanningConfigs).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningConfigActions).describe( + "Describe what action an entity can take." + ) }) ]; @@ -732,6 +798,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiTemplateActions).describe( + "Describe what action an entity can take." + ), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + conditions: PkiTemplateConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), @@ -742,6 +818,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index 965e25344..f4d7f4e6a 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -9,6 +9,7 @@ import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/per import { ActorType } from "@app/services/auth/auth-type"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { @@ -16,6 +17,7 @@ import { ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; +import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode, @@ -30,6 +32,7 @@ type TProjectUserAdditionalPrivilegeServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; projectMembershipDAL: Pick; permissionService: Pick; + accessApprovalRequestDAL: Pick; }; export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType< @@ -44,7 +47,8 @@ const unpackPermissions = (permissions: unknown) => export const projectUserAdditionalPrivilegeServiceFactory = ({ projectUserAdditionalPrivilegeDAL, projectMembershipDAL, - permissionService + permissionService, + accessApprovalRequestDAL }: TProjectUserAdditionalPrivilegeServiceFactoryDep) => { const create = async ({ slug, @@ -279,6 +283,15 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); + await accessApprovalRequestDAL.update( + { + privilegeId: userPrivilege.id + }, + { + privilegeDeletedAt: new Date(), + status: ApprovalStatus.REJECTED + } + ); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); return { ...deletedPrivilege, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts index f32439499..1d8ae24a2 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const secretApprovalPolicyApproverDALFactory = (db: TDbClient) => { const sapApproverOrm = ormify(db, TableName.SecretApprovalPolicyApprover); return sapApproverOrm; }; + +export type TSecretApprovalPolicyBypasserDALFactory = ReturnType; + +export const secretApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const sapBypasserOrm = ormify(db, TableName.SecretApprovalPolicyBypasser); + return sapBypasserOrm; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index 6644b14b8..fd8be93cf 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -1,11 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretApprovalPoliciesSchema, TableName, TSecretApprovalPolicies, TUsers } from "@app/db/schemas"; +import { + SecretApprovalPoliciesSchema, + TableName, + TSecretApprovalPolicies, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TSecretApprovalPolicyDALFactory = ReturnType; @@ -43,6 +49,22 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) + // Bypasser + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .select( tx.ref("id").withSchema("secretApprovalPolicyApproverUser").as("approverUserId"), @@ -58,6 +80,20 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema(TableName.Users).as("approverGroupFirstName"), tx.ref("lastName").withSchema(TableName.Users).as("approverGroupLastName") ) + .select( + tx.ref("id").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName") + ) + .select( + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema(TableName.Users).as("bypasserGroupEmail"), + tx.ref("firstName").withSchema(TableName.Users).as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema(TableName.Users).as("bypasserGroupLastName") + ) .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -143,7 +179,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { label: "approvers" as const, mapper: ({ approverUserId: id, approverUsername }) => ({ type: ApproverType.User, - name: approverUsername, + username: approverUsername, id }) }, @@ -155,6 +191,23 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { id }) }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + type: BypasserType.User, + username: bypasserUsername, + id + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + type: BypasserType.Group, + id + }) + }, { key: "approverUserId", label: "userApprovers" as const, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index bc2877ef2..696caf311 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -3,18 +3,21 @@ import picomatch from "picomatch"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; import { RequestState } from "../secret-approval-request/secret-approval-request-types"; -import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; +import { + TSecretApprovalPolicyApproverDALFactory, + TSecretApprovalPolicyBypasserDALFactory +} from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { TCreateSapDTO, @@ -36,6 +39,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectEnvDAL: Pick; userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; + secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; licenseService: Pick; secretApprovalRequestDAL: Pick; }; @@ -46,6 +50,7 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyDAL, permissionService, secretApprovalPolicyApproverDAL, + secretApprovalPolicyBypasserDAL, projectEnvDAL, userDAL, licenseService, @@ -59,6 +64,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectId, secretPath, environment, @@ -74,7 +80,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers.length && approvals > approvers.length) @@ -89,7 +95,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Create, + ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); @@ -107,6 +113,44 @@ export const secretApprovalPolicyServiceFactory = ({ message: `Environment with slug '${environment}' not found in project with ID ${projectId}` }); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.create( { @@ -158,6 +202,27 @@ export const secretApprovalPolicyServiceFactory = ({ })), tx ); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); @@ -166,6 +231,7 @@ export const secretApprovalPolicyServiceFactory = ({ const updateSecretApprovalPolicy = async ({ approvers, + bypassers, secretPath, name, actorId, @@ -186,7 +252,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const secretApprovalPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); @@ -204,10 +270,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Edit, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const plan = await licenseService.getPlan(actorOrgId); if (!plan.secretApproval) { @@ -217,6 +280,44 @@ export const secretApprovalPolicyServiceFactory = ({ }); } + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.username : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const updatedSap = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.updateById( secretApprovalPolicy.id, @@ -275,6 +376,28 @@ export const secretApprovalPolicyServiceFactory = ({ ); } + await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { @@ -304,7 +427,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Delete, + ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -343,10 +466,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; @@ -419,10 +539,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); return sapPolicy; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index a6fea6956..ed074336c 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -1,12 +1,16 @@ import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TCreateSapDTO = { approvals: number; secretPath?: string | null; environment: string; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectId: string; name: string; enforcementLevel: EnforcementLevel; @@ -17,7 +21,11 @@ export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; secretPath?: string | null; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals?: boolean; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 3877cbaf8..3bd35c3c8 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -6,6 +6,7 @@ import { TableName, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, + TUserGroupMembership, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -58,16 +59,36 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) - .leftJoin( - TableName.UserGroupMembership, + .leftJoin( + db(TableName.UserGroupMembership).as("approverUserGroupMembership"), `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, - `${TableName.UserGroupMembership}.groupId` + `approverUserGroupMembership.groupId` ) .leftJoin( db(TableName.Users).as("secretApprovalPolicyGroupApproverUser"), - `${TableName.UserGroupMembership}.userId`, + `approverUserGroupMembership.userId`, `secretApprovalPolicyGroupApproverUser.id` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + `secretApprovalPolicyGroupBypasserUser.id` + ) .leftJoin( TableName.SecretApprovalRequestReviewer, `${TableName.SecretApprovalRequest}.id`, @@ -81,7 +102,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), - tx.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), @@ -90,6 +111,20 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupFirstName"), tx.ref("lastName").withSchema("secretApprovalPolicyApproverUser").as("approverLastName"), tx.ref("lastName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupLastName"), + + // Bypasser fields + tx.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("email").withSchema("statusChangedByUser").as("statusChangedByUserEmail"), tx.ref("username").withSchema("statusChangedByUser").as("statusChangedByUserUsername"), tx.ref("firstName").withSchema("statusChangedByUser").as("statusChangedByUserFirstName"), @@ -121,7 +156,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.SecretApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -203,13 +238,51 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId: userId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + bypasserGroupUserId: userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserGroupFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdSAR" }); @@ -291,6 +364,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -342,6 +425,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser fields + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -355,7 +443,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -403,12 +491,22 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ userId: bypasserGroupUserId }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); @@ -440,6 +538,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -491,6 +599,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -504,7 +617,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -554,12 +667,24 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ + userId: bypasserGroupUserId + }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 6dbdc74b6..2a88136c2 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -63,11 +63,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { - ProjectPermissionApprovalActions, - ProjectPermissionSecretActions, - ProjectPermissionSub -} from "../permission/project-permission"; +import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; @@ -504,14 +500,14 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { policy, folderId, projectId } = secretApprovalRequest; + const { policy, folderId, projectId, bypassers } = secretApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this secret approval request has been deleted." }); } - const { hasRole, permission } = await permissionService.getProjectPermission({ + const { hasRole } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId, projectId, @@ -537,14 +533,9 @@ export const secretApprovalRequestServiceFactory = ({ approverId ? reviewers[approverId] === ApprovalStatus.APPROVED : false ).length; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; + const canBypass = !bypassers.length || bypassers.some((bypasser) => bypasser.userId === actorId); - if ( - !hasMinApproval && - !( - isSoftEnforcement && - permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval) - ) - ) + if (!hasMinApproval && !(isSoftEnforcement && canBypass)) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts new file mode 100644 index 000000000..dab424d74 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./mysql-credentials-rotation-constants"; +export * from "./mysql-credentials-rotation-schemas"; +export * from "./mysql-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts new file mode 100644 index 000000000..bae7a8166 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-constants.ts @@ -0,0 +1,23 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MYSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "MySQL Credentials", + type: SecretRotation.MySqlCredentials, + connection: AppConnection.MySql, + template: { + createUserStatement: `-- create user +CREATE USER 'infisical_user'@'%' IDENTIFIED BY 'temporary_password'; + +-- grant all privileges +GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user'@'%'; + +-- apply the privilege changes +FLUSH PRIVILEGES;`, + secretsMapping: { + username: "MYSQL_USERNAME", + password: "MYSQL_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..8eb048d89 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MySqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MySqlCredentials).extend({ + type: z.literal(SecretRotation.MySqlCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreateMySqlCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.MySqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdateMySqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.MySqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const MySqlCredentialsRotationListItemSchema = z.object({ + name: z.literal("MySQL Credentials"), + connection: z.literal(AppConnection.MySql), + type: z.literal(SecretRotation.MySqlCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts new file mode 100644 index 000000000..ccbbe1256 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mysql-credentials/mysql-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TMySqlConnection } from "@app/services/app-connection/mysql"; + +import { + CreateMySqlCredentialsRotationSchema, + MySqlCredentialsRotationListItemSchema, + MySqlCredentialsRotationSchema +} from "./mysql-credentials-rotation-schemas"; + +export type TMySqlCredentialsRotation = z.infer; + +export type TMySqlCredentialsRotationInput = z.infer; + +export type TMySqlCredentialsRotationListItem = z.infer; + +export type TMySqlCredentialsRotationWithConnection = TMySqlCredentialsRotation & { + connection: TMySqlConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index d67abea2b..a8c92e255 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -1,6 +1,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", + MySqlCredentials = "mysql-credentials", Auth0ClientSecret = "auth0-client-secret", AzureClientSecret = "azure-client-secret", AwsIamUserSecret = "aws-iam-user-secret", diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 1be7dc802..ea1b99107 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -9,6 +9,7 @@ import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret" import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; +import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; @@ -23,6 +24,7 @@ import { const SECRET_ROTATION_LIST_OPTIONS: Record = { [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION, [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION, + [SecretRotation.MySqlCredentials]: MYSQL_CREDENTIALS_ROTATION_LIST_OPTION, [SecretRotation.Auth0ClientSecret]: AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION, [SecretRotation.AzureClientSecret]: AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION, [SecretRotation.AwsIamUserSecret]: AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index f4ea75558..bd70336c4 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -4,6 +4,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", + [SecretRotation.MySqlCredentials]: "MySQL Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", [SecretRotation.AzureClientSecret]: "Azure Client Secret", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", @@ -13,6 +14,7 @@ export const SECRET_ROTATION_NAME_MAP: Record = { export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: AppConnection.Postgres, [SecretRotation.MsSqlCredentials]: AppConnection.MsSql, + [SecretRotation.MySqlCredentials]: AppConnection.MySql, [SecretRotation.Auth0ClientSecret]: AppConnection.Auth0, [SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets, [SecretRotation.AwsIamUserSecret]: AppConnection.AWS, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts index 4e66a7023..84a3435b6 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -122,6 +122,7 @@ type TRotationFactoryImplementation = TRotationFactory< const SECRET_ROTATION_FACTORY_MAP: Record = { [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, + [SecretRotation.MySqlCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation, [SecretRotation.Auth0ClientSecret]: auth0ClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AzureClientSecret]: azureClientSecretRotationFactory as TRotationFactoryImplementation, [SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation, diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts index b72bfba31..3fe42a983 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -39,6 +39,12 @@ import { TMsSqlCredentialsRotationListItem, TMsSqlCredentialsRotationWithConnection } from "./mssql-credentials"; +import { + TMySqlCredentialsRotation, + TMySqlCredentialsRotationInput, + TMySqlCredentialsRotationListItem, + TMySqlCredentialsRotationWithConnection +} from "./mysql-credentials"; import { TPostgresCredentialsRotation, TPostgresCredentialsRotationInput, @@ -51,6 +57,7 @@ import { SecretRotation } from "./secret-rotation-v2-enums"; export type TSecretRotationV2 = | TPostgresCredentialsRotation | TMsSqlCredentialsRotation + | TMySqlCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation | TLdapPasswordRotation @@ -59,6 +66,7 @@ export type TSecretRotationV2 = export type TSecretRotationV2WithConnection = | TPostgresCredentialsRotationWithConnection | TMsSqlCredentialsRotationWithConnection + | TMySqlCredentialsRotationWithConnection | TAuth0ClientSecretRotationWithConnection | TAzureClientSecretRotationWithConnection | TLdapPasswordRotationWithConnection @@ -74,6 +82,7 @@ export type TSecretRotationV2GeneratedCredentials = export type TSecretRotationV2Input = | TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput + | TMySqlCredentialsRotationInput | TAuth0ClientSecretRotationInput | TAzureClientSecretRotationInput | TLdapPasswordRotationInput @@ -82,6 +91,7 @@ export type TSecretRotationV2Input = export type TSecretRotationV2ListItem = | TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem + | TMySqlCredentialsRotationListItem | TAuth0ClientSecretRotationListItem | TAzureClientSecretRotationListItem | TLdapPasswordRotationListItem diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts index f6fdafe1d..cbbf44e7e 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -4,6 +4,7 @@ import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotatio import { AzureClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { MySqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; @@ -11,6 +12,7 @@ import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, Auth0ClientSecretRotationSchema, AzureClientSecretRotationSchema, LdapPasswordRotationSchema, diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts index 6eada6019..ab06074d7 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts @@ -1,13 +1,15 @@ import { z } from "zod"; import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { TMySqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas"; export type TSqlCredentialsRotationWithConnection = | TPostgresCredentialsRotationWithConnection - | TMsSqlCredentialsRotationWithConnection; + | TMsSqlCredentialsRotationWithConnection + | TMySqlCredentialsRotationWithConnection; export type TSqlCredentialsRotationGeneratedCredentials = z.infer< typeof SqlCredentialsRotationGeneratedCredentialsSchema diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index e3c6b6b5c..dd2b5a5ea 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -171,6 +171,13 @@ export const getDbSetQuery = (db: TDbProviderClients, variables: { username: str }; } + if (db === TDbProviderClients.MySql) { + return { + query: `ALTER USER ??@'%' IDENTIFIED BY '${variables.password}'`, + variables: [variables.username] + }; + } + // add more based on client return { query: `ALTER USER ?? IDENTIFIED BY '${variables.password}'`, diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts new file mode 100644 index 000000000..a8dd2eb42 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-constants.ts @@ -0,0 +1,9 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningDataSourceListItem } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION: TSecretScanningDataSourceListItem = { + name: "GitHub", + type: SecretScanningDataSource.GitHub, + connection: AppConnection.GitHubRadar +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts new file mode 100644 index 000000000..2dde97d7c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-factory.ts @@ -0,0 +1,230 @@ +import { join } from "path"; +import { ProbotOctokit } from "probot"; + +import { scanContentAndGetFindings } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + SecretScanningDataSource, + SecretScanningFindingSeverity, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + cloneRepository, + convertPatchLineToFileLineNumber, + replaceNonChangesWithNewlines +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + TSecretScanningFactoryGetDiffScanFindingsPayload, + TSecretScanningFactoryGetDiffScanResourcePayload, + TSecretScanningFactoryGetFullScanPath, + TSecretScanningFactoryInitialize, + TSecretScanningFactoryListRawResources, + TSecretScanningFactoryPostInitialization +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { titleCaseToCamelCase } from "@app/lib/fn"; +import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { listGitHubRadarRepositories, TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { TGitHubDataSourceWithConnection, TQueueGitHubResourceDiffScan } from "./github-secret-scanning-types"; + +export const GitHubSecretScanningFactory = () => { + const initialize: TSecretScanningFactoryInitialize = async ( + { connection, secretScanningV2DAL }, + callback + ) => { + const externalId = connection.credentials.installationId; + + const existingDataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId, + type: SecretScanningDataSource.GitHub + }); + + if (existingDataSource) + throw new BadRequestError({ + message: `A Data Source already exists for this GitHub Radar Connection in the Project with ID "${existingDataSource.projectId}"` + }); + + return callback({ + externalId + }); + }; + + const postInitialization: TSecretScanningFactoryPostInitialization = async () => { + // no post-initialization required + }; + + const listRawResources: TSecretScanningFactoryListRawResources = async ( + dataSource + ) => { + const { + connection, + config: { includeRepos } + } = dataSource; + + const repos = await listGitHubRadarRepositories(connection); + + const filteredRepos: typeof repos = []; + if (includeRepos.includes("*")) { + filteredRepos.push(...repos); + } else { + filteredRepos.push(...repos.filter((repo) => includeRepos.includes(repo.full_name))); + } + + return filteredRepos.map(({ id, full_name }) => ({ + name: full_name, + externalId: id.toString(), + type: SecretScanningResource.Repository + })); + }; + + const getFullScanPath: TSecretScanningFactoryGetFullScanPath = async ({ + dataSource, + resourceName, + tempFolder + }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { + data: { token } + } = await octokit.apps.createInstallationAccessToken({ + installation_id: Number(installationId) + }); + + const repoPath = join(tempFolder, "repo.git"); + + if (!GitHubRepositoryRegex.test(resourceName)) { + throw new Error("Invalid GitHub repository name"); + } + + await cloneRepository({ + cloneUrl: `https://x-access-token:${token}@github.com/${resourceName}.git`, + repoPath + }); + + return repoPath; + }; + + const getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload< + TQueueGitHubResourceDiffScan["payload"] + > = ({ repository }) => { + return { + name: repository.full_name, + externalId: repository.id.toString(), + type: SecretScanningResource.Repository + }; + }; + + const getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload< + TGitHubDataSourceWithConnection, + TQueueGitHubResourceDiffScan["payload"] + > = async ({ dataSource, payload, resourceName, configPath }) => { + const appCfg = getConfig(); + const { + connection: { + credentials: { installationId } + } + } = dataSource; + + const octokit = new ProbotOctokit({ + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId + } + }); + + const { commits, repository } = payload; + + const [owner, repo] = repository.full_name.split("/"); + + const allFindings: SecretMatch[] = []; + + for (const commit of commits) { + // eslint-disable-next-line no-await-in-loop + const commitData = await octokit.repos.getCommit({ + owner, + repo, + ref: commit.id + }); + + // eslint-disable-next-line no-continue + if (!commitData.data.files) continue; + + for (const file of commitData.data.files) { + if ((file.status === "added" || file.status === "modified") && file.patch) { + // eslint-disable-next-line + const findings = await scanContentAndGetFindings( + replaceNonChangesWithNewlines(`\n${file.patch}`), + configPath + ); + + const adjustedFindings = findings.map((finding) => { + const startLine = convertPatchLineToFileLineNumber(file.patch!, finding.StartLine); + const endLine = + finding.StartLine === finding.EndLine + ? startLine + : convertPatchLineToFileLineNumber(file.patch!, finding.EndLine); + const startColumn = finding.StartColumn - 1; // subtract 1 for + + const endColumn = finding.EndColumn - 1; // subtract 1 for + + + return { + ...finding, + StartLine: startLine, + EndLine: endLine, + StartColumn: startColumn, + EndColumn: endColumn, + File: file.filename, + Commit: commit.id, + Author: commit.author.name, + Email: commit.author.email ?? "", + Message: commit.message, + Fingerprint: `${commit.id}:${file.filename}:${finding.RuleID}:${startLine}:${startColumn}`, + Date: commit.timestamp, + Link: `https://github.com/${resourceName}/blob/${commit.id}/${file.filename}#L${startLine}` + }; + }); + + allFindings.push(...adjustedFindings); + } + } + } + + return allFindings.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: finding.Fingerprint, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); + }; + + return { + initialize, + postInitialization, + listRawResources, + getFullScanPath, + getDiffScanResourcePayload, + getDiffScanFindingsPayload + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts new file mode 100644 index 000000000..f1eec125c --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { + SecretScanningDataSource, + SecretScanningResource +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + BaseCreateSecretScanningDataSourceSchema, + BaseSecretScanningDataSourceSchema, + BaseSecretScanningFindingSchema, + BaseUpdateSecretScanningDataSourceSchema, + GitRepositoryScanFindingDetailsSchema +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-schemas"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { GitHubRepositoryRegex } from "@app/lib/regex"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const GitHubDataSourceConfigSchema = z.object({ + includeRepos: z + .array( + z + .string() + .min(1) + .max(256) + .refine((value) => value === "*" || GitHubRepositoryRegex.test(value), "Invalid repository name format") + ) + .nonempty("One or more repositories required") + .max(100, "Cannot configure more than 100 repositories") + .default(["*"]) + .describe(SecretScanningDataSources.CONFIG.GITHUB.includeRepos) +}); + +export const GitHubDataSourceSchema = BaseSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const CreateGitHubDataSourceSchema = BaseCreateSecretScanningDataSourceSchema({ + type: SecretScanningDataSource.GitHub, + isConnectionRequired: true +}) + .extend({ + config: GitHubDataSourceConfigSchema + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const UpdateGitHubDataSourceSchema = BaseUpdateSecretScanningDataSourceSchema(SecretScanningDataSource.GitHub) + .extend({ + config: GitHubDataSourceConfigSchema.optional() + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubDataSourceListItemSchema = z + .object({ + name: z.literal("GitHub"), + connection: z.literal(AppConnection.GitHubRadar), + type: z.literal(SecretScanningDataSource.GitHub) + }) + .describe( + JSON.stringify({ + title: "GitHub" + }) + ); + +export const GitHubFindingSchema = BaseSecretScanningFindingSchema.extend({ + resourceType: z.literal(SecretScanningResource.Repository), + dataSourceType: z.literal(SecretScanningDataSource.GitHub), + details: GitRepositoryScanFindingDetailsSchema +}); diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts new file mode 100644 index 000000000..8e38e04c1 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-service.ts @@ -0,0 +1,87 @@ +import { PushEvent } from "@octokit/webhooks-types"; + +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TSecretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { logger } from "@app/lib/logger"; + +import { TGitHubDataSource } from "./github-secret-scanning-types"; + +export const githubSecretScanningService = ( + secretScanningV2DAL: TSecretScanningV2DALFactory, + secretScanningV2Queue: Pick +) => { + const handleInstallationDeletedEvent = async (installationId: number) => { + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installationId), + type: SecretScanningDataSource.GitHub + }); + + if (!dataSource) { + logger.error( + `secretScanningV2RemoveEvent: GitHub - Could not find data source [installationId=${installationId}]` + ); + return; + } + + logger.info( + `secretScanningV2RemoveEvent: GitHub - installation deleted [installationId=${installationId}] [dataSourceId=${dataSource.id}]` + ); + + await secretScanningV2DAL.dataSources.updateById(dataSource.id, { + isDisconnected: true + }); + }; + + const handlePushEvent = async (payload: PushEvent) => { + const { commits, repository, installation } = payload; + + if (!commits || !repository || !installation) { + logger.warn( + `secretScanningV2PushEvent: GitHub - Insufficient data [commits=${commits?.length ?? 0}] [repository=${repository.name}] [installationId=${installation?.id}]` + ); + return; + } + + const dataSource = (await secretScanningV2DAL.dataSources.findOne({ + externalId: String(installation.id), + type: SecretScanningDataSource.GitHub + })) as TGitHubDataSource | undefined; + + if (!dataSource) { + logger.error( + `secretScanningV2PushEvent: GitHub - Could not find data source [installationId=${installation.id}]` + ); + return; + } + + const { + isAutoScanEnabled, + config: { includeRepos } + } = dataSource; + + if (!isAutoScanEnabled) { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to auto scan disabled [dataSourceId=${dataSource.id}] [installationId=${installation.id}]` + ); + return; + } + + if (includeRepos.includes("*") || includeRepos.includes(repository.full_name)) { + await secretScanningV2Queue.queueResourceDiffScan({ + dataSourceType: SecretScanningDataSource.GitHub, + payload, + dataSourceId: dataSource.id + }); + } else { + logger.info( + `secretScanningV2PushEvent: GitHub - ignoring due to repository not being present in config [installationId=${installation.id}] [dataSourceId=${dataSource.id}]` + ); + } + }; + + return { + handlePushEvent, + handleInstallationDeletedEvent + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts new file mode 100644 index 000000000..90b910d44 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/github-secret-scanning-types.ts @@ -0,0 +1,32 @@ +import { PushEvent } from "@octokit/webhooks-types"; +import { z } from "zod"; + +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar"; + +import { + CreateGitHubDataSourceSchema, + GitHubDataSourceListItemSchema, + GitHubDataSourceSchema, + GitHubFindingSchema +} from "./github-secret-scanning-schemas"; + +export type TGitHubDataSource = z.infer; + +export type TGitHubDataSourceInput = z.infer; + +export type TGitHubDataSourceListItem = z.infer; + +export type TGitHubFinding = z.infer; + +export type TGitHubDataSourceWithConnection = TGitHubDataSource & { + connection: TGitHubRadarConnection; +}; + +export type TQueueGitHubResourceDiffScan = { + dataSourceType: SecretScanningDataSource.GitHub; + payload: PushEvent; + dataSourceId: string; + resourceId: string; + scanId: string; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/github/index.ts b/backend/src/ee/services/secret-scanning-v2/github/index.ts new file mode 100644 index 000000000..b8bc755a6 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/github/index.ts @@ -0,0 +1,3 @@ +export * from "./github-secret-scanning-constants"; +export * from "./github-secret-scanning-schemas"; +export * from "./github-secret-scanning-types"; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts new file mode 100644 index 000000000..447ffc22a --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-dal.ts @@ -0,0 +1,460 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + SecretScanningResourcesSchema, + SecretScanningScansSchema, + TableName, + TSecretScanningDataSources +} from "@app/db/schemas"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { DatabaseError } from "@app/lib/errors"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindOpt +} from "@app/lib/knex"; + +export type TSecretScanningV2DALFactory = ReturnType; + +type TSecretScanningDataSourceFindFilter = Parameters>[0]; +type TSecretScanningDataSourceFindOptions = TFindOpt; + +const baseSecretScanningDataSourceQuery = ({ + filter = {}, + db, + tx +}: { + db: TDbClient; + filter?: TSecretScanningDataSourceFindFilter; + options?: TSecretScanningDataSourceFindOptions; + tx?: Knex; +}) => { + const query = (tx || db.replicaNode())(TableName.SecretScanningDataSource) + .join( + TableName.AppConnection, + `${TableName.SecretScanningDataSource}.connectionId`, + `${TableName.AppConnection}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningDataSource)) + .select( + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningDataSource, filter))); + } + + return query; +}; + +const expandSecretScanningDataSource = < + T extends Awaited>[number] +>( + dataSource: T +) => { + const { + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + connectionIsPlatformManagedCredentials, + ...el + } = dataSource; + + return { + ...el, + connectionId, + connection: connectionId + ? { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + } + : undefined + }; +}; + +export const secretScanningV2DALFactory = (db: TDbClient) => { + const dataSourceOrm = ormify(db, TableName.SecretScanningDataSource); + const resourceOrm = ormify(db, TableName.SecretScanningResource); + const scanOrm = ormify(db, TableName.SecretScanningScan); + const findingOrm = ormify(db, TableName.SecretScanningFinding); + const configOrm = ormify(db, TableName.SecretScanningConfig); + + const findDataSource = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }); + + if (!dataSources.length) return []; + + return dataSources.map(expandSecretScanningDataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Scanning Data Source" }); + } + }; + + const findDataSourceById = async (id: string, tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter: { id }, db, tx }).first(); + + if (dataSource) return expandSecretScanningDataSource(dataSource); + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - Secret Scanning Data Source" }); + } + }; + + const createDataSource = async (data: Parameters<(typeof dataSourceOrm)["create"]>[0], tx?: Knex) => { + const source = await dataSourceOrm.create(data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const updateDataSourceById = async ( + dataSourceId: string, + data: Parameters<(typeof dataSourceOrm)["updateById"]>[1], + tx?: Knex + ) => { + const source = await dataSourceOrm.updateById(dataSourceId, data, tx); + + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: source.id }, + db, + tx + }).first())!; + + return expandSecretScanningDataSource(dataSource); + }; + + const deleteDataSourceById = async (dataSourceId: string, tx?: Knex) => { + const dataSource = (await baseSecretScanningDataSourceQuery({ + filter: { id: dataSourceId }, + db, + tx + }).first())!; + + await dataSourceOrm.deleteById(dataSourceId, tx); + + return expandSecretScanningDataSource(dataSource); + }; + + const findOneDataSource = async (filter: Parameters<(typeof dataSourceOrm)["findOne"]>[0], tx?: Knex) => { + try { + const dataSource = await baseSecretScanningDataSourceQuery({ filter, db, tx }).first(); + + if (dataSource) { + return expandSecretScanningDataSource(dataSource); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Scanning Data Source" }); + } + }; + + const findDataSourceWithDetails = async (filter: Parameters<(typeof dataSourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const dataSources = await baseSecretScanningDataSourceQuery({ filter, db, tx }) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.dataSourceId`, + `${TableName.SecretScanningDataSource}.id` + ) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!dataSources.length) return []; + + const results = sqlNestRelationships({ + data: dataSources, + key: "id", + parentMapper: (dataSource) => expandSecretScanningDataSource(dataSource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage }) => ({ + id: scanId, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...dataSource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...dataSource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: scans.length ? findings.length : null + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Data Source" }); + } + }; + + const findResourcesWithDetails = async (filter: Parameters<(typeof resourceOrm)["find"]>[0], tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const resources = await (tx || db.replicaNode())(TableName.SecretScanningResource) + .where((qb) => { + if (filter) + void qb.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretScanningResource, filter))); + }) + .leftJoin( + TableName.SecretScanningScan, + `${TableName.SecretScanningScan}.resourceId`, + `${TableName.SecretScanningResource}.id` + ) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .where((qb) => { + void qb + .where(`${TableName.SecretScanningFinding}.status`, SecretScanningFindingStatus.Unresolved) + .orWhereNull(`${TableName.SecretScanningFinding}.status`); + }) + .select(selectAllTableCols(TableName.SecretScanningResource)) + .select( + db.ref("id").withSchema(TableName.SecretScanningScan).as("scanId"), + db.ref("status").withSchema(TableName.SecretScanningScan).as("scanStatus"), + db.ref("type").withSchema(TableName.SecretScanningScan).as("scanType"), + db.ref("statusMessage").withSchema(TableName.SecretScanningScan).as("scanStatusMessage"), + db.ref("createdAt").withSchema(TableName.SecretScanningScan).as("scanCreatedAt"), + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId") + ); + + if (!resources.length) return []; + + const results = sqlNestRelationships({ + data: resources, + key: "id", + parentMapper: (resource) => SecretScanningResourcesSchema.parse(resource), + childrenMapper: [ + { + key: "scanId", + label: "scans" as const, + mapper: ({ scanId, scanCreatedAt, scanStatus, scanStatusMessage, scanType }) => ({ + id: scanId, + type: scanType, + createdAt: scanCreatedAt, + status: scanStatus, + statusMessage: scanStatusMessage + }) + }, + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId }) => ({ + id: findingId + }) + } + ] + }); + + return results.map(({ scans, findings, ...resource }) => { + const lastScan = + scans && scans.length + ? scans.reduce((latest, current) => { + return new Date(current.createdAt) > new Date(latest.createdAt) ? current : latest; + }) + : null; + + return { + ...resource, + lastScanStatus: lastScan?.status ?? null, + lastScanStatusMessage: lastScan?.statusMessage ?? null, + lastScannedAt: lastScan?.createdAt ?? null, + unresolvedFindings: findings?.length ?? 0 + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details - Secret Scanning Resource" }); + } + }; + + const findScansWithDetailsByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + // TODO (scott): this query will probably need to be optimized + + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + .leftJoin( + TableName.SecretScanningFinding, + `${TableName.SecretScanningFinding}.scanId`, + `${TableName.SecretScanningScan}.id` + ) + .select(selectAllTableCols(TableName.SecretScanningScan)) + .select( + db.ref("status").withSchema(TableName.SecretScanningFinding).as("findingStatus"), + db.ref("id").withSchema(TableName.SecretScanningFinding).as("findingId"), + db.ref("name").withSchema(TableName.SecretScanningResource).as("resourceName") + ); + + if (!scans.length) return []; + + const results = sqlNestRelationships({ + data: scans, + key: "id", + parentMapper: (scan) => SecretScanningScansSchema.parse(scan), + childrenMapper: [ + { + key: "findingId", + label: "findings" as const, + mapper: ({ findingId, findingStatus }) => ({ + id: findingId, + status: findingStatus + }) + }, + { + key: "resourceId", + label: "resources" as const, + mapper: ({ resourceName }) => ({ + name: resourceName + }) + } + ] + }); + + return results.map(({ findings, resources, ...scan }) => { + return { + ...scan, + unresolvedFindings: + findings?.filter((finding) => finding.status === SecretScanningFindingStatus.Unresolved).length ?? 0, + resolvedFindings: + findings?.filter((finding) => finding.status !== SecretScanningFindingStatus.Unresolved).length ?? 0, + resourceName: resources[0].name + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Details By Data Source ID - Secret Scanning Scan" }); + } + }; + + const findScansByDataSourceId = async (dataSourceId: string, tx?: Knex) => { + try { + const scans = await (tx || db.replicaNode())(TableName.SecretScanningScan) + .leftJoin( + TableName.SecretScanningResource, + `${TableName.SecretScanningResource}.id`, + `${TableName.SecretScanningScan}.resourceId` + ) + .where(`${TableName.SecretScanningResource}.dataSourceId`, dataSourceId) + + .select(selectAllTableCols(TableName.SecretScanningScan)); + + return scans; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Data Source ID - Secret Scanning Scan" }); + } + }; + + return { + dataSources: { + ...dataSourceOrm, + find: findDataSource, + findById: findDataSourceById, + findOne: findOneDataSource, + create: createDataSource, + updateById: updateDataSourceById, + deleteById: deleteDataSourceById, + findWithDetails: findDataSourceWithDetails + }, + resources: { + ...resourceOrm, + findWithDetails: findResourcesWithDetails + }, + scans: { + ...scanOrm, + findWithDetailsByDataSourceId: findScansWithDetailsByDataSourceId, + findByDataSourceId: findScansByDataSourceId + }, + findings: findingOrm, + configs: configOrm + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts new file mode 100644 index 000000000..082f3d760 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-enums.ts @@ -0,0 +1,33 @@ +export enum SecretScanningDataSource { + GitHub = "github" +} + +export enum SecretScanningScanStatus { + Completed = "completed", + Failed = "failed", + Queued = "queued", + Scanning = "scanning" +} + +export enum SecretScanningScanType { + FullScan = "full-scan", + DiffScan = "diff-scan" +} + +export enum SecretScanningFindingStatus { + Resolved = "resolved", + Unresolved = "unresolved", + FalsePositive = "false-positive", + Ignore = "ignore" +} + +export enum SecretScanningResource { + Repository = "repository", + Project = "project" +} + +export enum SecretScanningFindingSeverity { + High = "high", + Medium = "medium", + Low = "low" +} diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts new file mode 100644 index 000000000..109afe5f3 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-factory.ts @@ -0,0 +1,19 @@ +import { GitHubSecretScanningFactory } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-factory"; + +import { SecretScanningDataSource } from "./secret-scanning-v2-enums"; +import { + TQueueSecretScanningResourceDiffScan, + TSecretScanningDataSourceCredentials, + TSecretScanningDataSourceWithConnection, + TSecretScanningFactory +} from "./secret-scanning-v2-types"; + +type TSecretScanningFactoryImplementation = TSecretScanningFactory< + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceCredentials, + TQueueSecretScanningResourceDiffScan["payload"] +>; + +export const SECRET_SCANNING_FACTORY_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubSecretScanningFactory as TSecretScanningFactoryImplementation +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts new file mode 100644 index 000000000..64a0ba4ed --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -0,0 +1,140 @@ +import { AxiosError } from "axios"; +import { exec } from "child_process"; +import RE2 from "re2"; + +import { readFindingsFile } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { SecretMatch } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION } from "@app/ee/services/secret-scanning-v2/github"; +import { titleCaseToCamelCase } from "@app/lib/fn"; + +import { SecretScanningDataSource, SecretScanningFindingSeverity } from "./secret-scanning-v2-enums"; +import { TCloneRepository, TGetFindingsPayload, TSecretScanningDataSourceListItem } from "./secret-scanning-v2-types"; + +const SECRET_SCANNING_SOURCE_LIST_OPTIONS: Record = { + [SecretScanningDataSource.GitHub]: GITHUB_SECRET_SCANNING_DATA_SOURCE_LIST_OPTION +}; + +export const listSecretScanningDataSourceOptions = () => { + return Object.values(SECRET_SCANNING_SOURCE_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const cloneRepository = async ({ cloneUrl, repoPath }: TCloneRepository): Promise => { + const command = `git clone ${cloneUrl} ${repoPath} --bare`; + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +}; + +export function scanDirectory(inputPath: string, outputPath: string, configPath?: string): Promise { + return new Promise((resolve, reject) => { + const command = `cd ${inputPath} && infisical scan --exit-code=77 -r "${outputPath}" ${configPath ? `-c ${configPath}` : ""}`; + exec(command, (error) => { + if (error && error.code !== 77) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +export const scanGitRepositoryAndGetFindings = async ( + scanPath: string, + findingsPath: string, + configPath?: string +): TGetFindingsPayload => { + await scanDirectory(scanPath, findingsPath, configPath); + + const findingsData = JSON.parse(await readFindingsFile(findingsPath)) as SecretMatch[]; + + return findingsData.map( + ({ + // discard match and secret as we don't want to store + Match, + Secret, + ...finding + }) => ({ + details: titleCaseToCamelCase(finding), + fingerprint: `${finding.Fingerprint}:${finding.StartColumn}`, + severity: SecretScanningFindingSeverity.High, + rule: finding.RuleID + }) + ); +}; + +export const replaceNonChangesWithNewlines = (patch: string) => { + return patch + .split("\n") + .map((line) => { + // Keep added lines (remove the + prefix) + if (line.startsWith("+") && !line.startsWith("+++")) { + return line.substring(1); + } + + // Replace everything else with newlines to maintain line positioning + + return ""; + }) + .join("\n"); +}; + +const HunkHeaderRegex = new RE2(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + +export const convertPatchLineToFileLineNumber = (patch: string, patchLineNumber: number) => { + const lines = patch.split("\n"); + let currentPatchLine = 0; + let currentNewLine = 0; + + for (const line of lines) { + currentPatchLine += 1; + + // Hunk header: @@ -a,b +c,d @@ + const hunkHeaderMatch = HunkHeaderRegex.match(line); + if (hunkHeaderMatch) { + const startLine = parseInt(hunkHeaderMatch[1], 10); + currentNewLine = startLine; + // eslint-disable-next-line no-continue + continue; + } + + if (currentPatchLine === patchLineNumber) { + return currentNewLine; + } + + if (line.startsWith("+++")) { + // eslint-disable-next-line no-continue + continue; // skip file metadata lines + } + + // Advance only if the line exists in the new file + if (line.startsWith("+") || line.startsWith(" ")) { + currentNewLine += 1; + } + } + + return currentNewLine; +}; + +const MAX_MESSAGE_LENGTH = 1024; + +export const parseScanErrorMessage = (err: unknown): string => { + let errorMessage: string; + + if (err instanceof AxiosError) { + errorMessage = err?.response?.data + ? JSON.stringify(err?.response?.data) + : (err?.message ?? "An unknown error occurred."); + } else { + errorMessage = (err as Error)?.message || "An unknown error occurred."; + } + + return errorMessage.length <= MAX_MESSAGE_LENGTH + ? errorMessage + : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts new file mode 100644 index 000000000..f41a2b5c2 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-maps.ts @@ -0,0 +1,14 @@ +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const SECRET_SCANNING_DATA_SOURCE_NAME_MAP: Record = { + [SecretScanningDataSource.GitHub]: "GitHub" +}; + +export const SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP: Record = { + [SecretScanningDataSource.GitHub]: AppConnection.GitHubRadar +}; + +export const AUTO_SYNC_DESCRIPTION_HELPER: Record = { + [SecretScanningDataSource.GitHub]: { verb: "push", noun: "repositories" } +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts new file mode 100644 index 000000000..3747af81f --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -0,0 +1,626 @@ +import { join } from "path"; + +import { ProjectMembershipRole, TSecretScanningFindings } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + createTempFolder, + deleteTempFolder, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { + parseScanErrorMessage, + scanGitRepositoryAndGetFindings +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnection } from "@app/services/app-connection/app-connection-types"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { + SecretScanningDataSource, + SecretScanningResource, + SecretScanningScanStatus, + SecretScanningScanType +} from "./secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "./secret-scanning-v2-factory"; +import { + TFindingsPayload, + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification, + TSecretScanningDataSourceWithConnection +} from "./secret-scanning-v2-types"; + +type TSecretRotationV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + secretScanningV2DAL: TSecretScanningV2DALFactory; + smtpService: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + auditLogService: Pick; + keyStore: Pick; +}; + +export type TSecretScanningV2QueueServiceFactory = Awaited>; + +export const secretScanningV2QueueServiceFactory = async ({ + queueService, + secretScanningV2DAL, + projectMembershipDAL, + projectDAL, + smtpService, + kmsService, + auditLogService, + keyStore +}: TSecretRotationV2QueueServiceFactoryDep) => { + const queueDataSourceFullScan = async ( + dataSource: TSecretScanningDataSourceWithConnection, + resourceExternalId?: string + ) => { + try { + const { type } = dataSource; + + const factory = SECRET_SCANNING_FACTORY_MAP[type](); + + const rawResources = await factory.listRawResources(dataSource); + + let filteredRawResources = rawResources; + + // TODO: should add individual resource fetch to factory + if (resourceExternalId) { + filteredRawResources = rawResources.filter((resource) => resource.externalId === resourceExternalId); + } + + if (!filteredRawResources.length) { + throw new BadRequestError({ + message: `${resourceExternalId ? `Resource with "ID" ${resourceExternalId} could not be found.` : "Data source has no resources to scan"}. Ensure your data source config is correct and not filtering out scanning resources.` + }); + } + + for (const resource of filteredRawResources) { + // eslint-disable-next-line no-await-in-loop + if (await keyStore.getItem(KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId))) { + throw new BadRequestError({ message: `A scan is already in progress for resource "${resource.name}"` }); + } + } + + await secretScanningV2DAL.resources.transaction(async (tx) => { + const resources = await secretScanningV2DAL.resources.upsert( + filteredRawResources.map((rawResource) => ({ + ...rawResource, + dataSourceId: dataSource.id + })), + ["externalId", "dataSourceId"], + tx + ); + + const scans = await secretScanningV2DAL.scans.insertMany( + resources.map((resource) => ({ + resourceId: resource.id, + type: SecretScanningScanType.FullScan + })), + tx + ); + + for (const scan of scans) { + // eslint-disable-next-line no-await-in-loop + await queueService.queuePg(QueueJobs.SecretScanningV2FullScan, { + scanId: scan.id, + resourceId: scan.resourceId, + dataSourceId: dataSource.id + }); + } + }); + } catch (error) { + logger.error(error, `Failed to queue full-scan for data source with ID "${dataSource.id}"`); + + if (error instanceof BadRequestError) throw error; + + throw new InternalServerError({ message: `Failed to queue scan: ${(error as Error).message}` }); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2FullScan, + async ([job]) => { + const { scanId, resourceId, dataSourceId } = job.data as TQueueSecretScanningDataSourceFullScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[scanId=${scanId}] [resourceId=${resourceId}] [dataSourceId=${dataSourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const tempFolder = await createTempFolder(); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + let lock: Awaited> | undefined; + + try { + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.SecretScanningLock(dataSource.id, resource.externalId)], + 60 * 1000 * 5 + ); + } catch (e) { + throw new Error("Failed to acquire scanning lock."); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + + const findingsPath = join(tempFolder, "findings.json"); + + const scanPath = await factory.getFullScanPath({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + tempFolder + }); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + let findingsPayload: TFindingsPayload; + switch (resource.type) { + case SecretScanningResource.Repository: + case SecretScanningResource.Project: + findingsPayload = await scanGitRepositoryAndGetFindings(scanPath, findingsPath, configPath); + break; + default: + throw new Error("Unhandled resource type"); + } + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed, + statusMessage: null + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: false, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.FullScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Full Scan Complete ${logDetails} findings=[${findingsPayload.length}]`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.FullScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Full Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + await lock?.release(); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + const queueResourceDiffScan = async ({ + payload, + dataSourceId, + dataSourceType + }: Pick) => { + const factory = SECRET_SCANNING_FACTORY_MAP[dataSourceType as SecretScanningDataSource](); + + const resourcePayload = factory.getDiffScanResourcePayload(payload); + + try { + const { resourceId, scanId } = await secretScanningV2DAL.resources.transaction(async (tx) => { + const [resource] = await secretScanningV2DAL.resources.upsert( + [ + { + ...resourcePayload, + dataSourceId + } + ], + ["externalId", "dataSourceId"], + tx + ); + + const scan = await secretScanningV2DAL.scans.create( + { + resourceId: resource.id, + type: SecretScanningScanType.DiffScan + }, + tx + ); + + return { + resourceId: resource.id, + scanId: scan.id + }; + }); + + await queueService.queuePg(QueueJobs.SecretScanningV2DiffScan, { + payload, + dataSourceId, + dataSourceType, + scanId, + resourceId + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to queue diff scan [dataSourceId=${dataSourceId}] [resourceExternalId=${resourcePayload.externalId}]` + ); + } + }; + + await queueService.startPg( + QueueJobs.SecretScanningV2DiffScan, + async ([job]) => { + const { payload, dataSourceId, resourceId, scanId } = job.data as TQueueSecretScanningResourceDiffScan; + const { retryCount, retryLimit } = job; + + const logDetails = `[dataSourceId=${dataSourceId}] [scanId=${scanId}] [resourceId=${resourceId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) throw new Error(`Data source with ID "${dataSourceId}" not found`); + + const resource = await secretScanningV2DAL.resources.findById(resourceId); + + if (!resource) throw new Error(`Resource with ID "${resourceId}" not found`); + + const factory = SECRET_SCANNING_FACTORY_MAP[dataSource.type as SecretScanningDataSource](); + + const tempFolder = await createTempFolder(); + + try { + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Scanning + } + ); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId: dataSource.projectId + }); + + let configPath: string | undefined; + + if (config && config.content) { + configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, config.content); + } + + const findingsPayload = await factory.getDiffScanFindingsPayload({ + dataSource: { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceName: resource.name, + payload, + configPath + }); + + const allFindings = await secretScanningV2DAL.findings.transaction(async (tx) => { + let findings: TSecretScanningFindings[] = []; + + if (findingsPayload.length) { + findings = await secretScanningV2DAL.findings.upsert( + findingsPayload.map((finding) => ({ + ...finding, + projectId: dataSource.projectId, + dataSourceName: dataSource.name, + dataSourceType: dataSource.type, + resourceName: resource.name, + resourceType: resource.type, + scanId + })), + ["projectId", "fingerprint"], + tx, + ["resourceName", "dataSourceName"] + ); + } + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Completed + } + ); + + return findings; + }); + + const newFindings = allFindings.filter((finding) => finding.scanId === scanId); + + if (newFindings.length) { + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Completed, + resourceName: resource.name, + isDiffScan: true, + dataSource, + numberOfSecrets: newFindings.length, + scanId + }); + } + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Completed, + scanType: SecretScanningScanType.DiffScan, + numberOfSecretsDetected: findingsPayload.length + } + } + }); + + logger.info(`secretScanningV2Queue: Diff Scan Complete ${logDetails}`); + } catch (error) { + if (retryCount === retryLimit) { + const errorMessage = parseScanErrorMessage(error); + + await secretScanningV2DAL.scans.update( + { id: scanId }, + { + status: SecretScanningScanStatus.Failed, + statusMessage: errorMessage + } + ); + + await queueService.queuePg(QueueJobs.SecretScanningV2SendNotification, { + status: SecretScanningScanStatus.Failed, + resourceName: resource.name, + dataSource, + errorMessage + }); + + await auditLogService.createAuditLog({ + projectId: dataSource.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.SECRET_SCANNING_DATA_SOURCE_SCAN, + metadata: { + dataSourceId: dataSource.id, + dataSourceType: dataSource.type, + resourceId: resource.id, + resourceType: resource.type, + scanId, + scanStatus: SecretScanningScanStatus.Failed, + scanType: SecretScanningScanType.DiffScan + } + } + }); + } + + logger.error(error, `secretScanningV2Queue: Diff Scan Failed ${logDetails}`); + throw error; + } finally { + await deleteTempFolder(tempFolder); + } + }, + { + batchSize: 1, + workerCount: 20, + pollingIntervalSeconds: 1 + } + ); + + await queueService.startPg( + QueueJobs.SecretScanningV2SendNotification, + async ([job]) => { + const { dataSource, resourceName, ...payload } = job.data as TQueueSecretScanningSendNotification; + + const appCfg = getConfig(); + + if (!appCfg.isSmtpConfigured) return; + + try { + const { projectId } = dataSource; + + logger.info( + `secretScanningV2Queue: Sending Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const timestamp = new Date().toISOString(); + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: + payload.status === SecretScanningScanStatus.Completed + ? SmtpTemplates.SecretScanningV2SecretsDetected + : SmtpTemplates.SecretScanningV2ScanFailed, + subjectLine: + payload.status === SecretScanningScanStatus.Completed + ? "Incident Alert: Secret(s) Leaked" + : `Secret Scanning Failed`, + substitutions: + payload.status === SecretScanningScanStatus.Completed + ? { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName, + numberOfSecrets: payload.numberOfSecrets, + isDiffScan: payload.isDiffScan, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` + ), + timestamp + } + : { + dataSourceName: dataSource.name, + resourceName, + projectName: project.name, + timestamp, + errorMessage: payload.errorMessage, + url: encodeURI( + `${appCfg.SITE_URL}/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` + ) + } + }); + } catch (error) { + logger.error( + error, + `secretScanningV2Queue: Failed to Send Status Notification [dataSourceId=${dataSource.id}] [resourceName=${resourceName}] [status=${payload.status}]` + ); + throw error; + } + }, + { + batchSize: 1, + workerCount: 5, + pollingIntervalSeconds: 1 + } + ); + + return { + queueDataSourceFullScan, + queueResourceDiffScan + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts new file mode 100644 index 000000000..832b73bda --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-schemas.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +import { SecretScanningDataSourcesSchema, SecretScanningFindingsSchema } from "@app/db/schemas"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { SecretScanningDataSources } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +type SecretScanningDataSourceSchemaOpts = { + type: SecretScanningDataSource; + isConnectionRequired: boolean; +}; + +export const BaseSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + SecretScanningDataSourcesSchema.omit({ + // unique to provider + type: true, + connectionId: true, + config: true, + encryptedCredentials: true + }).extend({ + type: z.literal(type), + connectionId: isConnectionRequired ? z.string().uuid() : z.null(), + connection: isConnectionRequired + ? z.object({ + app: z.literal(SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]), + name: z.string(), + id: z.string().uuid() + }) + : z.null() + }); + +export const BaseCreateSecretScanningDataSourceSchema = ({ + type, + isConnectionRequired +}: SecretScanningDataSourceSchemaOpts) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.CREATE(type).name), + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretScanningDataSources.CREATE(type).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.CREATE(type).description), + connectionId: isConnectionRequired + ? z.string().uuid().describe(SecretScanningDataSources.CREATE(type).connectionId) + : z.undefined(), + isAutoScanEnabled: z + .boolean() + .optional() + .default(true) + .describe(SecretScanningDataSources.CREATE(type).isAutoScanEnabled) + }); + +export const BaseUpdateSecretScanningDataSourceSchema = (type: SecretScanningDataSource) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretScanningDataSources.UPDATE(type).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretScanningDataSources.UPDATE(type).description), + isAutoScanEnabled: z.boolean().optional().describe(SecretScanningDataSources.UPDATE(type).isAutoScanEnabled) + }); + +export const GitRepositoryScanFindingDetailsSchema = z.object({ + description: z.string(), + startLine: z.number(), + endLine: z.number(), + startColumn: z.number(), + endColumn: z.number(), + file: z.string(), + link: z.string(), + symlinkFile: z.string(), + commit: z.string(), + entropy: z.number(), + author: z.string(), + email: z.string(), + date: z.string(), + message: z.string(), + tags: z.string().array(), + ruleID: z.string(), + fingerprint: z.string() +}); + +export const BaseSecretScanningFindingSchema = SecretScanningFindingsSchema.omit({ + dataSourceType: true, + resourceType: true, + details: true +}); diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts new file mode 100644 index 000000000..05449bd0d --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-service.ts @@ -0,0 +1,875 @@ +import { ForbiddenError } from "@casl/ability"; +import { join } from "path"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionSecretScanningConfigActions, + ProjectPermissionSecretScanningDataSourceActions, + ProjectPermissionSecretScanningFindingActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { + createTempFolder, + deleteTempFolder, + scanContentAndGetFindings, + writeTextToFile +} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns"; +import { githubSecretScanningService } from "@app/ee/services/secret-scanning-v2/github/github-secret-scanning-service"; +import { SecretScanningFindingStatus } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { SECRET_SCANNING_FACTORY_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-factory"; +import { listSecretScanningDataSourceOptions } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-fns"; +import { + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; +import { + TCreateSecretScanningDataSourceDTO, + TDeleteSecretScanningDataSourceDTO, + TFindSecretScanningDataSourceByIdDTO, + TFindSecretScanningDataSourceByNameDTO, + TListSecretScanningDataSourcesByProjectId, + TSecretScanningDataSource, + TSecretScanningDataSourceWithConnection, + TSecretScanningDataSourceWithDetails, + TSecretScanningFinding, + TSecretScanningResourceWithDetails, + TSecretScanningScanWithDetails, + TTriggerSecretScanningDataSourceDTO, + TUpdateSecretScanningDataSourceDTO, + TUpdateSecretScanningFindingDTO, + TUpsertSecretScanningConfigDTO +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TAppConnection } from "@app/services/app-connection/app-connection-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { TSecretScanningV2DALFactory } from "./secret-scanning-v2-dal"; +import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue"; + +export type TSecretScanningV2ServiceFactoryDep = { + secretScanningV2DAL: TSecretScanningV2DALFactory; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + secretScanningV2Queue: Pick< + TSecretScanningV2QueueServiceFactory, + "queueDataSourceFullScan" | "queueResourceDiffScan" + >; + kmsService: Pick; +}; + +export type TSecretScanningV2ServiceFactory = ReturnType; + +export const secretScanningV2ServiceFactory = ({ + secretScanningV2DAL, + permissionService, + appConnectionService, + licenseService, + secretScanningV2Queue, + kmsService +}: TSecretScanningV2ServiceFactoryDep) => { + const $checkListSecretScanningDataSourcesByProjectIdPermissions = async ( + projectId: string, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Sources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + }; + + const listSecretScanningDataSourcesByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.find({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSource[]; + }; + + const listSecretScanningDataSourcesWithDetailsByProjectId = async ( + { projectId, type }: TListSecretScanningDataSourcesByProjectId, + actor: OrgServiceActor + ) => { + await $checkListSecretScanningDataSourcesByProjectIdPermissions(projectId, actor); + + const dataSources = await secretScanningV2DAL.dataSources.findWithDetails({ + ...(type && { type }), + projectId + }); + + return dataSources as TSecretScanningDataSourceWithDetails[]; + }; + + const findSecretScanningDataSourceById = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const findSecretScanningDataSourceByName = async ( + { type, sourceName, projectId }: TFindSecretScanningDataSourceByNameDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + // we prevent conflicting names within a folder + const dataSource = await secretScanningV2DAL.dataSources.findOne({ + name: sourceName, + projectId + }); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with name "${sourceName}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Read, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSource.id}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + return dataSource as TSecretScanningDataSource; + }; + + const createSecretScanningDataSource = async ( + payload: TCreateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to create Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: payload.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Create, + ProjectPermissionSub.SecretScanningDataSources + ); + + let connection: TAppConnection | null = null; + if (payload.connectionId) { + // validates permission to connect and app is valid for data source + connection = await appConnectionService.connectAppConnectionById( + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[payload.type], + payload.connectionId, + actor + ); + } + + const factory = SECRET_SCANNING_FACTORY_MAP[payload.type](); + + try { + const createdDataSource = await factory.initialize( + { + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + secretScanningV2DAL + }, + async ({ credentials, externalId }) => { + let encryptedCredentials: Buffer | null = null; + + if (credentials) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: payload.projectId + }); + + const { cipherTextBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + encryptedCredentials = cipherTextBlob; + } + + return secretScanningV2DAL.dataSources.transaction(async (tx) => { + const dataSource = await secretScanningV2DAL.dataSources.create( + { + encryptedCredentials, + externalId, + ...payload + }, + tx + ); + + await factory.postInitialization({ + payload, + connection: connection as TSecretScanningDataSourceWithConnection["connection"], + dataSourceId: dataSource.id, + credentials + }); + + return dataSource; + }); + } + ); + + if (payload.isAutoScanEnabled) { + try { + await secretScanningV2Queue.queueDataSourceFullScan({ + ...createdDataSource, + connection + } as TSecretScanningDataSourceWithConnection); + } catch { + // silently fail, don't want to block creation, they'll try scanning when they don't see anything and get the error + } + } + + return createdDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${payload.projectId}"` + }); + } + + throw err; + } + }; + + const updateSecretScanningDataSource = async ( + { type, dataSourceId, ...payload }: TUpdateSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to update Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Edit, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + try { + const updatedDataSource = await secretScanningV2DAL.dataSources.updateById(dataSourceId, payload); + + return updatedDataSource as TSecretScanningDataSource; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `A Secret Scanning Data Source with the name "${payload.name}" already exists for the project with ID "${dataSource.projectId}"` + }); + } + + throw err; + } + }; + + const deleteSecretScanningDataSource = async ( + { type, dataSourceId }: TDeleteSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to delete Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.Delete, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + // TODO: clean up webhooks + + await secretScanningV2DAL.dataSources.deleteById(dataSourceId); + + return dataSource as TSecretScanningDataSource; + }; + + const triggerSecretScanningDataSourceScan = async ( + { type, dataSourceId, resourceId }: TTriggerSecretScanningDataSourceDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to trigger scan for Secret Scanning Data Source due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.TriggerScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + let connection: TAppConnection | null = null; + if (dataSource.connection) connection = await decryptAppConnection(dataSource.connection, kmsService); + + let resourceExternalId: string | undefined; + + if (resourceId) { + const resource = await secretScanningV2DAL.resources.findOne({ id: resourceId, dataSourceId }); + if (!resource) { + throw new NotFoundError({ + message: `Could not find Secret Scanning Resource with ID "${resourceId}" for Data Source with ID "${dataSourceId}"` + }); + } + resourceExternalId = resource.externalId; + } + + await secretScanningV2Queue.queueDataSourceFullScan( + { + ...dataSource, + connection + } as TSecretScanningDataSourceWithConnection, + resourceExternalId + ); + + return dataSource as TSecretScanningDataSource; + }; + + const listSecretScanningResourcesByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.find({ + dataSourceId + }); + + return { resources, projectId: dataSource.projectId }; + }; + + const listSecretScanningScansByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findByDataSourceId(dataSourceId); + + return { scans, projectId: dataSource.projectId }; + }; + + const listSecretScanningResourcesWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Resources due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadResources, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const resources = await secretScanningV2DAL.resources.findWithDetails({ dataSourceId }); + + return { resources: resources as TSecretScanningResourceWithDetails[], projectId: dataSource.projectId }; + }; + + const listSecretScanningScansWithDetailsByDataSourceId = async ( + { type, dataSourceId }: TFindSecretScanningDataSourceByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Scans due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const dataSource = await secretScanningV2DAL.dataSources.findById(dataSourceId); + + if (!dataSource) + throw new NotFoundError({ + message: `Could not find ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source with ID "${dataSourceId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: dataSource.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningDataSourceActions.ReadScans, + ProjectPermissionSub.SecretScanningDataSources + ); + + if (type !== dataSource.type) + throw new BadRequestError({ + message: `Secret Scanning Data Source with ID "${dataSourceId}" is not configured for ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]}` + }); + + const scans = await secretScanningV2DAL.scans.findWithDetailsByDataSourceId(dataSourceId); + + return { scans: scans as TSecretScanningScanWithDetails[], projectId: dataSource.projectId }; + }; + + const getSecretScanningUnresolvedFindingsCountByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const [finding] = await secretScanningV2DAL.findings.find( + { + projectId, + status: SecretScanningFindingStatus.Unresolved + }, + { count: true } + ); + + return Number(finding?.count ?? 0); + }; + + const listSecretScanningFindingsByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Read, + ProjectPermissionSub.SecretScanningFindings + ); + + const findings = await secretScanningV2DAL.findings.find({ + projectId + }); + + return findings as TSecretScanningFinding[]; + }; + + const updateSecretScanningFindingById = async ( + { findingId, remarks, status }: TUpdateSecretScanningFindingDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Findings due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const finding = await secretScanningV2DAL.findings.findById(findingId); + + if (!finding) + throw new NotFoundError({ + message: `Could not find Secret Scanning Finding with ID "${findingId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId: finding.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningFindingActions.Update, + ProjectPermissionSub.SecretScanningFindings + ); + + const updatedFinding = await secretScanningV2DAL.findings.updateById(findingId, { + remarks, + status + }); + + return { finding: updatedFinding as TSecretScanningFinding, projectId: finding.projectId }; + }; + + const findSecretScanningConfigByProjectId = async (projectId: string, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Read, + ProjectPermissionSub.SecretScanningConfigs + ); + + const config = await secretScanningV2DAL.configs.findOne({ + projectId + }); + + return ( + config ?? { content: null, projectId, updatedAt: null } // using default config + ); + }; + + const upsertSecretScanningConfig = async ( + { projectId, content }: TUpsertSecretScanningConfigDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretScanning) + throw new BadRequestError({ + message: + "Failed to access Secret Scanning Configuration due to plan restriction. Upgrade plan to enable Secret Scanning." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretScanning, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretScanningConfigActions.Update, + ProjectPermissionSub.SecretScanningConfigs + ); + + if (content) { + const tempFolder = await createTempFolder(); + try { + const configPath = join(tempFolder, "infisical-scan.toml"); + await writeTextToFile(configPath, content); + + // just checking if config parses + await scanContentAndGetFindings("", configPath); + } catch (e) { + throw new BadRequestError({ + message: "Unable to parse configuration: Check syntax and formatting." + }); + } finally { + await deleteTempFolder(tempFolder); + } + } + + const [config] = await secretScanningV2DAL.configs.upsert( + [ + { + projectId, + content + } + ], + "projectId" + ); + + return config; + }; + + return { + listSecretScanningDataSourceOptions, + listSecretScanningDataSourcesByProjectId, + listSecretScanningDataSourcesWithDetailsByProjectId, + findSecretScanningDataSourceById, + findSecretScanningDataSourceByName, + createSecretScanningDataSource, + updateSecretScanningDataSource, + deleteSecretScanningDataSource, + triggerSecretScanningDataSourceScan, + listSecretScanningResourcesByDataSourceId, + listSecretScanningScansByDataSourceId, + listSecretScanningResourcesWithDetailsByDataSourceId, + listSecretScanningScansWithDetailsByDataSourceId, + getSecretScanningUnresolvedFindingsCountByProjectId, + listSecretScanningFindingsByProjectId, + updateSecretScanningFindingById, + findSecretScanningConfigByProjectId, + upsertSecretScanningConfig, + github: githubSecretScanningService(secretScanningV2DAL, secretScanningV2Queue) + }; +}; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts new file mode 100644 index 000000000..3ee5851d7 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-types.ts @@ -0,0 +1,189 @@ +import { + TSecretScanningDataSources, + TSecretScanningFindingsInsert, + TSecretScanningResources, + TSecretScanningScans +} from "@app/db/schemas"; +import { + TGitHubDataSource, + TGitHubDataSourceInput, + TGitHubDataSourceListItem, + TGitHubDataSourceWithConnection, + TGitHubFinding, + TQueueGitHubResourceDiffScan +} from "@app/ee/services/secret-scanning-v2/github"; +import { TSecretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { + SecretScanningDataSource, + SecretScanningFindingStatus, + SecretScanningScanStatus +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; + +export type TSecretScanningDataSource = TGitHubDataSource; + +export type TSecretScanningDataSourceWithDetails = TSecretScanningDataSource & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningResourceWithDetails = TSecretScanningResources & { + lastScannedAt?: Date | null; + lastScanStatus?: SecretScanningScanStatus | null; + lastScanStatusMessage?: string | null; + unresolvedFindings: number; +}; + +export type TSecretScanningScanWithDetails = TSecretScanningScans & { + unresolvedFindings: number; + resolvedFindings: number; + resourceName: string; +}; + +export type TSecretScanningDataSourceWithConnection = TGitHubDataSourceWithConnection; + +export type TSecretScanningDataSourceInput = TGitHubDataSourceInput; + +export type TSecretScanningDataSourceListItem = TGitHubDataSourceListItem; + +export type TSecretScanningFinding = TGitHubFinding; + +export type TListSecretScanningDataSourcesByProjectId = { + projectId: string; + type?: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByIdDTO = { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TFindSecretScanningDataSourceByNameDTO = { + sourceName: string; + projectId: string; + type: SecretScanningDataSource; +}; + +export type TCreateSecretScanningDataSourceDTO = Pick< + TSecretScanningDataSource, + "description" | "name" | "projectId" +> & { + connectionId?: string; + type: SecretScanningDataSource; + isAutoScanEnabled?: boolean; + config: Partial; +}; + +export type TUpdateSecretScanningDataSourceDTO = Partial< + Omit +> & { + dataSourceId: string; + type: SecretScanningDataSource; +}; + +export type TDeleteSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; +}; + +export type TTriggerSecretScanningDataSourceDTO = { + type: SecretScanningDataSource; + dataSourceId: string; + resourceId?: string; +}; + +export type TQueueSecretScanningDataSourceFullScan = { + dataSourceId: string; + resourceId: string; + scanId: string; +}; + +export type TQueueSecretScanningResourceDiffScan = TQueueGitHubResourceDiffScan; + +export type TQueueSecretScanningSendNotification = { + dataSource: TSecretScanningDataSources; + resourceName: string; +} & ( + | { status: SecretScanningScanStatus.Failed; errorMessage: string } + | { status: SecretScanningScanStatus.Completed; numberOfSecrets: number; scanId: string; isDiffScan: boolean } +); + +export type TCloneRepository = { + cloneUrl: string; + repoPath: string; +}; + +export type TSecretScanningFactoryListRawResources = ( + dataSource: T +) => Promise[]>; + +export type TSecretScanningFactoryGetDiffScanResourcePayload< + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (payload: P) => Pick; + +export type TSecretScanningFactoryGetFullScanPath = (parameters: { + dataSource: T; + resourceName: string; + tempFolder: string; +}) => Promise; + +export type TSecretScanningFactoryGetDiffScanFindingsPayload< + T extends TSecretScanningDataSourceWithConnection, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = (parameters: { dataSource: T; resourceName: string; payload: P; configPath?: string }) => Promise; + +export type TSecretScanningDataSourceRaw = NonNullable< + Awaited> +>; + +export type TSecretScanningFactoryInitialize< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = ( + params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + secretScanningV2DAL: TSecretScanningV2DALFactory; + }, + callback: (parameters: { credentials?: C; externalId?: string }) => Promise +) => Promise; + +export type TSecretScanningFactoryPostInitialization< + T extends TSecretScanningDataSourceWithConnection["connection"] | undefined = undefined, + C extends TSecretScanningDataSourceCredentials = undefined +> = (params: { + payload: TCreateSecretScanningDataSourceDTO; + connection: T; + credentials: C; + dataSourceId: string; +}) => Promise; + +export type TSecretScanningFactory< + T extends TSecretScanningDataSourceWithConnection, + C extends TSecretScanningDataSourceCredentials, + P extends TQueueSecretScanningResourceDiffScan["payload"] +> = () => { + listRawResources: TSecretScanningFactoryListRawResources; + getFullScanPath: TSecretScanningFactoryGetFullScanPath; + initialize: TSecretScanningFactoryInitialize; + postInitialization: TSecretScanningFactoryPostInitialization; + getDiffScanResourcePayload: TSecretScanningFactoryGetDiffScanResourcePayload

; + getDiffScanFindingsPayload: TSecretScanningFactoryGetDiffScanFindingsPayload; +}; + +export type TFindingsPayload = Pick[]; +export type TGetFindingsPayload = Promise; + +export type TUpdateSecretScanningFindingDTO = { + status?: SecretScanningFindingStatus; + remarks?: string | null; + findingId: string; +}; + +export type TUpsertSecretScanningConfigDTO = { + projectId: string; + content: string | null; +}; + +export type TSecretScanningDataSourceCredentials = undefined; diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts new file mode 100644 index 000000000..4f34791f8 --- /dev/null +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-union-schemas.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +import { GitHubDataSourceSchema, GitHubFindingSchema } from "@app/ee/services/secret-scanning-v2/github"; + +export const SecretScanningDataSourceSchema = z.discriminatedUnion("type", [GitHubDataSourceSchema]); + +export const SecretScanningFindingSchema = z.discriminatedUnion("resourceType", [GitHubFindingSchema]); diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts index 2e74a1caf..ceb239adf 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-fns.ts @@ -65,9 +65,9 @@ export function runInfisicalScanOnRepo(repoPath: string, outputPath: string): Pr }); } -export function runInfisicalScan(inputPath: string, outputPath: string): Promise { +export function runInfisicalScan(inputPath: string, outputPath: string, configPath?: string): Promise { return new Promise((resolve, reject) => { - const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}"`; + const command = `cat "${inputPath}" | infisical scan --exit-code=77 --pipe -r "${outputPath}" ${configPath ? `-c "${configPath}"` : ""}`; exec(command, (error) => { if (error && error.code !== 77) { reject(error); @@ -138,14 +138,14 @@ export async function scanFullRepoContentAndGetFindings( } } -export async function scanContentAndGetFindings(textContent: string): Promise { +export async function scanContentAndGetFindings(textContent: string, configPath?: string): Promise { const tempFolder = await createTempFolder(); const filePath = join(tempFolder, "content.txt"); const findingsPath = join(tempFolder, "findings.json"); try { await writeTextToFile(filePath, textContent); - await runInfisicalScan(filePath, findingsPath); + await runInfisicalScan(filePath, findingsPath, configPath); const findingsData = await readFindingsFile(findingsPath); return JSON.parse(findingsData) as SecretMatch[]; } finally { diff --git a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts index 3f14a41e3..6990febc3 100644 --- a/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts +++ b/backend/src/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types.ts @@ -9,6 +9,7 @@ export type SecretMatch = { Match: string; Secret: string; File: string; + Link: string; SymlinkFile: string; Commit: string; Entropy: number; diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts index 5b05b2301..5fb39a0ec 100644 --- a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts @@ -117,6 +117,7 @@ export const OCIVaultSyncFns = { syncSecrets: async (secretSync: TOCIVaultSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { compartmentOcid, vaultOcid, keyOcid } } = secretSync; @@ -213,7 +214,7 @@ export const OCIVaultSyncFns = { // Update and delete secrets for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; // Only update / delete active secrets if (variable.lifecycleState === vault.models.SecretSummary.LifecycleState.Active) { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 2d2e6d5ae..d3f19168a 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -10,7 +10,8 @@ export const PgSqlLock = { KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), - SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), + CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`) } as const; export type TKeyStoreFactory = ReturnType; @@ -37,6 +38,8 @@ export const KeyStorePrefixes = { `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, + SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => + `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, CaOrderCertificateForSubscriberLock: (subscriberId: string) => `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 5dcc812ad..3734cbf21 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -3,6 +3,12 @@ import { SECRET_ROTATION_CONNECTION_MAP, SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SecretScanningDataSource } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-enums"; +import { + AUTO_SYNC_DESCRIPTION_HELPER, + SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP, + SECRET_SCANNING_DATA_SOURCE_NAME_MAP +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; @@ -57,7 +63,8 @@ export enum ApiDocsTags { SshHostGroups = "SSH Host Groups", KmsKeys = "KMS Keys", KmsEncryption = "KMS Encryption", - KmsSigning = "KMS Signing" + KmsSigning = "KMS Signing", + SecretScanning = "Secret Scanning" } export const GROUPS = { @@ -393,6 +400,8 @@ export const KUBERNETES_AUTH = { caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.", @@ -410,6 +419,8 @@ export const KUBERNETES_AUTH = { caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The new comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.", @@ -2432,3 +2443,81 @@ export const SecretRotations = { } } }; + +export const SecretScanningDataSources = { + LIST: (type?: SecretScanningDataSource) => ({ + projectId: `The ID of the project to list ${type ? SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type] : "Scanning"} Data Sources from.` + }), + GET_BY_ID: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.` + }), + GET_BY_NAME: (type: SecretScanningDataSource) => ({ + sourceName: `The name of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to retrieve.`, + projectId: `The ID of the project the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source is located in.` + }), + CREATE: (type: SecretScanningDataSource) => { + const sourceType = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + return { + name: `The name of the ${sourceType} Data Source to create. Must be slug-friendly.`, + description: `An optional description for the ${sourceType} Data Source.`, + projectId: `The ID of the project to create the ${sourceType} Data Source in.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[type]] + } Connection to use for this Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The configuration parameters to use for this Data Source.` + }; + }, + UPDATE: (type: SecretScanningDataSource) => { + const typeName = SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]; + const autoScanDescription = AUTO_SYNC_DESCRIPTION_HELPER[type]; + + return { + dataSourceId: `The ID of the ${typeName} Data Source to be updated.`, + name: `The updated name of the ${typeName} Data Source. Must be slug-friendly.`, + description: `The updated description of the ${typeName} Data Source.`, + isAutoScanEnabled: `Whether scans should be automatically performed when a ${autoScanDescription.verb} occurs to ${autoScanDescription.noun} associated with this Data Source.`, + config: `The updated configuration parameters to use for this Data Source.` + }; + }, + DELETE: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to be deleted.` + }), + SCAN: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to trigger a scan for.`, + resourceId: `The ID of the individual Data Source resource to trigger a scan for.` + }), + LIST_RESOURCES: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list resources from.` + }), + LIST_SCANS: (type: SecretScanningDataSource) => ({ + dataSourceId: `The ID of the ${SECRET_SCANNING_DATA_SOURCE_NAME_MAP[type]} Data Source to list scans for.` + }), + CONFIG: { + GITHUB: { + includeRepos: 'The repositories to include when scanning. Defaults to all repositories (["*"]).' + } + } +}; + +export const SecretScanningFindings = { + LIST: { + projectId: `The ID of the project to list Secret Scanning Findings from.` + }, + UPDATE: { + findingId: "The ID of the Secret Scanning Finding to update.", + status: "The updated status of the specified Secret Scanning Finding.", + remarks: "Remarks pertaining to the status of this finding." + } +}; + +export const SecretScanningConfigs = { + GET_BY_PROJECT_ID: { + projectId: `The ID of the project to retrieve the Secret Scanning Configuration for.` + }, + UPDATE: { + projectId: "The ID of the project to update the Secret Scanning Configuration for.", + content: "The contents of the Secret Scanning Configuration file." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9e7a34232..d817b52c1 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { QueueWorkerProfile } from "@app/lib/types"; + import { removeTrailingSlash } from "../fn"; import { CustomLogger } from "../logger/logger"; import { zpStr } from "../zod"; @@ -69,6 +71,7 @@ const envSchema = z ENCRYPTION_KEY: zpStr(z.string().optional()), ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), + QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All), HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options @@ -230,6 +233,14 @@ const envSchema = z INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()), + // github radar app + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET: zpStr(z.string().optional()), + // gcp app INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()), @@ -302,6 +313,13 @@ const envSchema = z Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + isSecretScanningV2Configured: + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) && + Boolean(data.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET), isHsmConfigured: Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 87db80343..65d0b7859 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -32,3 +32,24 @@ export const shake = ( return acc; }, {} as T); }; + +export const titleCaseToCamelCase = (obj: unknown): unknown => { + if (typeof obj !== "object" || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item: object) => titleCaseToCamelCase(item)); + } + + const result: Record = {}; + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const camelKey = key.charAt(0).toLowerCase() + key.slice(1); + result[camelKey] = titleCaseToCamelCase((obj as Record)[key]); + } + } + + return result; +}; diff --git a/backend/src/lib/gateway/gateway.ts b/backend/src/lib/gateway/gateway.ts new file mode 100644 index 000000000..179c29fc8 --- /dev/null +++ b/backend/src/lib/gateway/gateway.ts @@ -0,0 +1,411 @@ +/* eslint-disable no-await-in-loop */ +import crypto from "node:crypto"; +import net from "node:net"; + +import quicDefault, * as quicModule from "@infisical/quic"; +import axios from "axios"; +import https from "https"; + +import { BadRequestError } from "../errors"; +import { logger } from "../logger"; +import { + GatewayProxyProtocol, + IGatewayProxyOptions, + IGatewayProxyServer, + TGatewayTlsOptions, + TPingGatewayAndVerifyDTO +} from "./types"; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; // 1 second + +const quic = quicDefault || quicModule; + +const parseSubjectDetails = (data: string) => { + const values: Record = {}; + data.split("\n").forEach((el) => { + const [key, value] = el.split("="); + values[key.trim()] = value.trim(); + }); + return values; +}; + +const createQuicConnection = async ( + relayHost: string, + relayPort: number, + tlsOptions: TGatewayTlsOptions, + identityId: string, + orgId: string +) => { + const client = await quic.QUICClient.createQUICClient({ + host: relayHost, + port: relayPort, + config: { + ca: tlsOptions.ca, + cert: tlsOptions.cert, + key: tlsOptions.key, + applicationProtos: ["infisical-gateway"], + verifyPeer: true, + verifyCallback: async (certs) => { + if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; + const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); + const caCertificate = new crypto.X509Certificate(tlsOptions.ca); + const isValidServerCertificate = serverCertificate.verify(caCertificate.publicKey); + if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; + + const subjectDetails = parseSubjectDetails(serverCertificate.subject); + if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { + return quic.native.CryptoError.CertificateUnknown; + } + + if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { + return quic.native.CryptoError.CertificateExpired; + } + + const formatedRelayHost = + process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; + if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; + }, + maxIdleTimeout: 90000, + keepAliveIntervalTime: 30000 + }, + crypto: { + ops: { + randomBytes: async (data) => { + crypto.getRandomValues(new Uint8Array(data)); + } + } + } + }); + return client; +}; + +export const pingGatewayAndVerify = async ({ + relayHost, + relayPort, + tlsOptions, + maxRetries = DEFAULT_MAX_RETRIES, + identityId, + orgId +}: TPingGatewayAndVerifyDTO) => { + let lastError: Error | null = null; + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + message: (err as Error)?.message, + error: err as Error + }); + }); + + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + try { + const stream = quicClient.connection.newStream("bidi"); + const pingWriter = stream.writable.getWriter(); + await pingWriter.write(Buffer.from("PING\n")); + pingWriter.releaseLock(); + + // Read PONG response + const reader = stream.readable.getReader(); + const { value, done } = await reader.read(); + + if (done) { + throw new Error("Gateway closed before receiving PONG"); + } + + const response = Buffer.from(value).toString(); + + if (response !== "PONG\n" && response !== "PONG") { + throw new Error(`Failed to Ping. Unexpected response: ${response}`); + } + + reader.releaseLock(); + return; + } catch (err) { + lastError = err as Error; + + if (attempt < maxRetries) { + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_RETRY_DELAY); + }); + } + } finally { + await quicClient.destroy(); + } + } + + logger.error(lastError); + throw new BadRequestError({ + message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` + }); +}; + +const setupProxyServer = async ({ + targetPort, + targetHost, + tlsOptions, + relayHost, + relayPort, + identityId, + orgId, + protocol = GatewayProxyProtocol.Tcp, + httpsAgent +}: { + targetHost: string; + targetPort: number; + relayPort: number; + relayHost: string; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol?: GatewayProxyProtocol; + httpsAgent?: https.Agent; +}): Promise => { + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + error: err as Error + }); + }); + const proxyErrorMsg = [""]; + + return new Promise((resolve, reject) => { + const server = net.createServer(); + + let streamClosed = false; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + server.on("connection", async (clientConn) => { + try { + clientConn.setKeepAlive(true, 30000); // 30 seconds + clientConn.setNoDelay(true); + + const stream = quicClient.connection.newStream("bidi"); + + const forwardWriter = stream.writable.getWriter(); + let command: string; + + if (protocol === GatewayProxyProtocol.Http) { + const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http) + command = `FORWARD-HTTP ${targetUrl}`; + logger.debug(`Using HTTP proxy mode: ${command.trim()}`); + + // extract ca certificate from httpsAgent if present + if (httpsAgent && targetHost.startsWith("https://")) { + const agentOptions = httpsAgent.options; + if (agentOptions && agentOptions.ca) { + const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca; + const caB64 = Buffer.from(caCert as string).toString("base64"); + command += ` ca=${caB64}`; + + const rejectUnauthorized = agentOptions.rejectUnauthorized !== false; + command += ` verify=${rejectUnauthorized}`; + + logger.debug(`Using HTTP proxy mode [command=${command.trim()}]`); + } + } + + command += "\n"; + } else if (protocol === GatewayProxyProtocol.Tcp) { + // For TCP mode, send FORWARD-TCP with host:port + command = `FORWARD-TCP ${targetHost}:${targetPort}\n`; + logger.debug(`Using TCP proxy mode: ${command.trim()}`); + } else { + throw new BadRequestError({ + message: `Invalid protocol: ${protocol as string}` + }); + } + + await forwardWriter.write(Buffer.from(command)); + forwardWriter.releaseLock(); + + // Set up bidirectional copy + const setupCopy = () => { + // Client to QUIC + // eslint-disable-next-line + (async () => { + const writer = stream.writable.getWriter(); + + // Create a handler for client data + clientConn.on("data", (chunk) => { + writer.write(chunk).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + + // Handle client connection close + clientConn.on("end", () => { + if (!streamClosed) { + try { + writer.close().catch((err) => { + logger.debug(err, "Error closing writer (already closed)"); + }); + } catch (error) { + logger.debug(error, "Error in writer close"); + } + } + }); + + clientConn.on("error", (clientConnErr) => { + writer.abort(clientConnErr?.message).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + })(); + + // QUIC to Client + void (async () => { + try { + const reader = stream.readable.getReader(); + + let reading = true; + while (reading) { + const { value, done } = await reader.read(); + + if (done) { + reading = false; + clientConn.end(); // Close client connection when QUIC stream ends + break; + } + + // Write data to TCP client + const canContinue = clientConn.write(Buffer.from(value)); + + // Handle backpressure + if (!canContinue) { + await new Promise((res) => { + clientConn.once("drain", res); + }); + } + } + } catch (err) { + proxyErrorMsg.push((err as Error)?.message); + clientConn.destroy(); + } + })(); + }; + + setupCopy(); + // Handle connection closure + clientConn.on("close", () => { + if (!streamClosed) { + streamClosed = true; + stream.destroy().catch((err) => { + logger.debug(err, "Stream already destroyed during close event"); + }); + } + }); + + const cleanup = async () => { + try { + clientConn?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying client connection"); + } + + if (!streamClosed) { + streamClosed = true; + try { + await stream.destroy(); + } catch (err) { + logger.debug(err, "Error destroying stream (might be already closed)"); + } + } + }; + + clientConn.on("error", (clientConnErr) => { + logger.error(clientConnErr, "Client socket error"); + cleanup().catch((err) => { + logger.error(err, "Client conn cleanup"); + }); + }); + + clientConn.on("end", () => { + cleanup().catch((err) => { + logger.error(err, "Client conn end"); + }); + }); + } catch (err) { + logger.error(err, "Failed to establish target connection:"); + clientConn.end(); + reject(err); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.on("close", () => { + quicClient?.destroy().catch((err) => { + logger.error(err, "Failed to destroy quic client"); + }); + }); + + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get server port")); + return; + } + + logger.info(`Gateway proxy started on port ${address.port} (${protocol} mode)`); + resolve({ + server, + port: address.port, + cleanup: async () => { + try { + server.close(); + } catch (err) { + logger.debug(err, "Error closing server"); + } + + try { + await quicClient?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying QUIC client"); + } + }, + getProxyError: () => proxyErrorMsg.join(",") + }); + }); + }); +}; + +export const withGatewayProxy = async ( + callback: (port: number, httpsAgent?: https.Agent) => Promise, + options: IGatewayProxyOptions +): Promise => { + const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId, protocol, httpsAgent } = options; + + // Setup the proxy server + const { port, cleanup, getProxyError } = await setupProxyServer({ + targetHost, + targetPort, + relayPort, + relayHost, + tlsOptions, + identityId, + orgId, + protocol, + httpsAgent + }); + + try { + // Execute the callback with the allocated port + return await callback(port, httpsAgent); + } catch (err) { + const proxyErrorMessage = getProxyError(); + if (proxyErrorMessage) { + logger.error(new Error(proxyErrorMessage), "Failed to proxy"); + } + logger.error(err, "Failed to do gateway"); + let errorMessage = proxyErrorMessage || (err as Error)?.message; + if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { + errorMessage = (err.response?.data as { message: string }).message; + } + + throw new BadRequestError({ message: errorMessage }); + } finally { + // Ensure cleanup happens regardless of success or failure + await cleanup(); + } +}; diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 7a94c6384..9292473e5 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -1,386 +1,2 @@ -/* eslint-disable no-await-in-loop */ -import crypto from "node:crypto"; -import net from "node:net"; - -import quicDefault, * as quicModule from "@infisical/quic"; - -import { BadRequestError } from "../errors"; -import { logger } from "../logger"; - -const DEFAULT_MAX_RETRIES = 3; -const DEFAULT_RETRY_DELAY = 1000; // 1 second - -const quic = quicDefault || quicModule; - -const parseSubjectDetails = (data: string) => { - const values: Record = {}; - data.split("\n").forEach((el) => { - const [key, value] = el.split("="); - values[key.trim()] = value.trim(); - }); - return values; -}; - -type TTlsOption = { ca: string; cert: string; key: string }; - -const createQuicConnection = async ( - relayHost: string, - relayPort: number, - tlsOptions: TTlsOption, - identityId: string, - orgId: string -) => { - const client = await quic.QUICClient.createQUICClient({ - host: relayHost, - port: relayPort, - config: { - ca: tlsOptions.ca, - cert: tlsOptions.cert, - key: tlsOptions.key, - applicationProtos: ["infisical-gateway"], - verifyPeer: true, - verifyCallback: async (certs) => { - if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; - const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); - const caCertificate = new crypto.X509Certificate(tlsOptions.ca); - const isValidServerCertificate = serverCertificate.checkIssued(caCertificate); - if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; - - const subjectDetails = parseSubjectDetails(serverCertificate.subject); - if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { - return quic.native.CryptoError.CertificateUnknown; - } - - if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { - return quic.native.CryptoError.CertificateExpired; - } - - const formatedRelayHost = - process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; - if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; - }, - maxIdleTimeout: 90000, - keepAliveIntervalTime: 30000 - }, - crypto: { - ops: { - randomBytes: async (data) => { - crypto.getRandomValues(new Uint8Array(data)); - } - } - } - }); - return client; -}; - -type TPingGatewayAndVerifyDTO = { - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - maxRetries?: number; - identityId: string; - orgId: string; -}; - -export const pingGatewayAndVerify = async ({ - relayHost, - relayPort, - tlsOptions, - maxRetries = DEFAULT_MAX_RETRIES, - identityId, - orgId -}: TPingGatewayAndVerifyDTO) => { - let lastError: Error | null = null; - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - message: (err as Error)?.message, - error: err as Error - }); - }); - - for (let attempt = 1; attempt <= maxRetries; attempt += 1) { - try { - const stream = quicClient.connection.newStream("bidi"); - const pingWriter = stream.writable.getWriter(); - await pingWriter.write(Buffer.from("PING\n")); - pingWriter.releaseLock(); - - // Read PONG response - const reader = stream.readable.getReader(); - const { value, done } = await reader.read(); - - if (done) { - throw new Error("Gateway closed before receiving PONG"); - } - - const response = Buffer.from(value).toString(); - - if (response !== "PONG\n" && response !== "PONG") { - throw new Error(`Failed to Ping. Unexpected response: ${response}`); - } - - reader.releaseLock(); - return; - } catch (err) { - lastError = err as Error; - - if (attempt < maxRetries) { - await new Promise((resolve) => { - setTimeout(resolve, DEFAULT_RETRY_DELAY); - }); - } - } finally { - await quicClient.destroy(); - } - } - - logger.error(lastError); - throw new BadRequestError({ - message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` - }); -}; - -interface TProxyServer { - server: net.Server; - port: number; - cleanup: () => Promise; - getProxyError: () => string; -} - -const setupProxyServer = async ({ - targetPort, - targetHost, - tlsOptions, - relayHost, - relayPort, - identityId, - orgId -}: { - targetHost: string; - targetPort: number; - relayPort: number; - relayHost: string; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -}): Promise => { - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - error: err as Error - }); - }); - const proxyErrorMsg = [""]; - - return new Promise((resolve, reject) => { - const server = net.createServer(); - - let streamClosed = false; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - server.on("connection", async (clientConn) => { - try { - clientConn.setKeepAlive(true, 30000); // 30 seconds - clientConn.setNoDelay(true); - - const stream = quicClient.connection.newStream("bidi"); - // Send FORWARD-TCP command - const forwardWriter = stream.writable.getWriter(); - await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`)); - forwardWriter.releaseLock(); - - // Set up bidirectional copy - const setupCopy = () => { - // Client to QUIC - // eslint-disable-next-line - (async () => { - const writer = stream.writable.getWriter(); - - // Create a handler for client data - clientConn.on("data", (chunk) => { - writer.write(chunk).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - - // Handle client connection close - clientConn.on("end", () => { - if (!streamClosed) { - try { - writer.close().catch((err) => { - logger.debug(err, "Error closing writer (already closed)"); - }); - } catch (error) { - logger.debug(error, "Error in writer close"); - } - } - }); - - clientConn.on("error", (clientConnErr) => { - writer.abort(clientConnErr?.message).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - })(); - - // QUIC to Client - void (async () => { - try { - const reader = stream.readable.getReader(); - - let reading = true; - while (reading) { - const { value, done } = await reader.read(); - - if (done) { - reading = false; - clientConn.end(); // Close client connection when QUIC stream ends - break; - } - - // Write data to TCP client - const canContinue = clientConn.write(Buffer.from(value)); - - // Handle backpressure - if (!canContinue) { - await new Promise((res) => { - clientConn.once("drain", res); - }); - } - } - } catch (err) { - proxyErrorMsg.push((err as Error)?.message); - clientConn.destroy(); - } - })(); - }; - - setupCopy(); - // Handle connection closure - clientConn.on("close", () => { - if (!streamClosed) { - streamClosed = true; - stream.destroy().catch((err) => { - logger.debug(err, "Stream already destroyed during close event"); - }); - } - }); - - const cleanup = async () => { - try { - clientConn?.destroy(); - } catch (err) { - logger.debug(err, "Error destroying client connection"); - } - - if (!streamClosed) { - streamClosed = true; - try { - await stream.destroy(); - } catch (err) { - logger.debug(err, "Error destroying stream (might be already closed)"); - } - } - }; - - clientConn.on("error", (clientConnErr) => { - logger.error(clientConnErr, "Client socket error"); - cleanup().catch((err) => { - logger.error(err, "Client conn cleanup"); - }); - }); - - clientConn.on("end", () => { - cleanup().catch((err) => { - logger.error(err, "Client conn end"); - }); - }); - } catch (err) { - logger.error(err, "Failed to establish target connection:"); - clientConn.end(); - reject(err); - } - }); - - server.on("error", (err) => { - reject(err); - }); - - server.on("close", () => { - quicClient?.destroy().catch((err) => { - logger.error(err, "Failed to destroy quic client"); - }); - }); - - server.listen(0, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to get server port")); - return; - } - - logger.info("Gateway proxy started"); - resolve({ - server, - port: address.port, - cleanup: async () => { - try { - server.close(); - } catch (err) { - logger.debug(err, "Error closing server"); - } - - try { - await quicClient?.destroy(); - } catch (err) { - logger.debug(err, "Error destroying QUIC client"); - } - }, - getProxyError: () => proxyErrorMsg.join(",") - }); - }); - }); -}; - -interface ProxyOptions { - targetHost: string; - targetPort: number; - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -} - -export const withGatewayProxy = async ( - callback: (port: number) => Promise, - options: ProxyOptions -): Promise => { - const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; - - // Setup the proxy server - const { port, cleanup, getProxyError } = await setupProxyServer({ - targetHost, - targetPort, - relayPort, - relayHost, - tlsOptions, - identityId, - orgId - }); - - try { - // Execute the callback with the allocated port - return await callback(port); - } catch (err) { - const proxyErrorMessage = getProxyError(); - if (proxyErrorMessage) { - logger.error(new Error(proxyErrorMessage), "Failed to proxy"); - } - logger.error(err, "Failed to do gateway"); - throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message }); - } finally { - // Ensure cleanup happens regardless of success or failure - await cleanup(); - } -}; +export { pingGatewayAndVerify, withGatewayProxy } from "./gateway"; +export { GatewayHttpProxyActions, GatewayProxyProtocol } from "./types"; diff --git a/backend/src/lib/gateway/types.ts b/backend/src/lib/gateway/types.ts new file mode 100644 index 000000000..5d0ac8237 --- /dev/null +++ b/backend/src/lib/gateway/types.ts @@ -0,0 +1,42 @@ +import net from "node:net"; + +import https from "https"; + +export type TGatewayTlsOptions = { ca: string; cert: string; key: string }; + +export enum GatewayProxyProtocol { + Http = "http", + Tcp = "tcp" +} + +export enum GatewayHttpProxyActions { + InjectGatewayK8sServiceAccountToken = "inject-k8s-sa-auth-token" +} + +export interface IGatewayProxyOptions { + targetHost: string; + targetPort: number; + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol: GatewayProxyProtocol; + httpsAgent?: https.Agent; +} + +export type TPingGatewayAndVerifyDTO = { + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + maxRetries?: number; + identityId: string; + orgId: string; +}; + +export interface IGatewayProxyServer { + server: net.Server; + port: number; + cleanup: () => Promise; + getProxyError: () => string; +} diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index 2e17bff20..5949afe33 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -179,13 +179,18 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "batchInsert" }); } }, - upsert: async (data: readonly Tables[Tname]["insert"][], onConflictField: keyof Tables[Tname]["base"], tx?: Knex) => { + upsert: async ( + data: readonly Tables[Tname]["insert"][], + onConflictField: keyof Tables[Tname]["base"] | Array, + tx?: Knex, + mergeColumns?: (keyof Knex.ResolveTableType, "update">)[] | undefined + ) => { try { if (!data.length) return []; const res = await (tx || db)(tableName) .insert(data as never) .onConflict(onConflictField as never) - .merge() + .merge(mergeColumns) .returning("*"); return res; } catch (error) { diff --git a/backend/src/lib/regex/index.ts b/backend/src/lib/regex/index.ts index be9430669..c472f8d5d 100644 --- a/backend/src/lib/regex/index.ts +++ b/backend/src/lib/regex/index.ts @@ -9,3 +9,5 @@ export const DistinguishedNameRegex = export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/); export const LdapUrlRegex = new RE2(/^ldaps?:\/\//); + +export const GitHubRepositoryRegex = new RE2(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/); diff --git a/backend/src/lib/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts index a83c9efc2..08343e962 100644 --- a/backend/src/lib/template/validate-handlebars.ts +++ b/backend/src/lib/template/validate-handlebars.ts @@ -19,3 +19,15 @@ export const validateHandlebarTemplate = (templateName: string, template: string throw new BadRequestError({ message: `Template sanitization failed: ${templateName}` }); }); }; + +export const isValidHandleBarTemplate = (template: string, dto: SanitizationArg) => { + const parsedAst = handlebars.parse(template); + return parsedAst.body.every((el) => { + if (el.type === "ContentStatement") return true; + if (el.type === "MustacheStatement" && "path" in el) { + const { path } = el as { type: "MustacheStatement"; path: { type: "PathExpression"; original: string } }; + if (path.type === "PathExpression" && dto?.allowedExpressions?.(path.original)) return true; + } + return false; + }); +}; diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 9f063172f..49d8893be 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -78,3 +78,9 @@ export type OrgServiceActor = { authMethod: ActorAuthMethod; orgId: string; }; + +export enum QueueWorkerProfile { + All = "all", + Standard = "standard", + SecretScanning = "secret-scanning" +} diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 14fbfe6c4..25677841d 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -11,9 +11,15 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { + TQueueSecretScanningDataSourceFullScan, + TQueueSecretScanningResourceDiffScan, + TQueueSecretScanningSendNotification +} from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-types"; import { getConfig } from "@app/lib/config/env"; import { buildRedisFromConfig, TRedisConfigKeys } from "@app/lib/config/redis"; import { logger } from "@app/lib/logger"; +import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TFailedIntegrationSyncEmailsPayload, @@ -55,7 +61,8 @@ export enum QueueName { AppConnectionSecretSync = "app-connection-secret-sync", SecretRotationV2 = "secret-rotation-v2", FolderTreeCheckpoint = "folder-tree-checkpoint", - InvalidateCache = "invalidate-cache" + InvalidateCache = "invalidate-cache", + SecretScanningV2 = "secret-scanning-v2" } export enum QueueJobs { @@ -90,6 +97,9 @@ export enum QueueJobs { SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", CreateFolderTreeCheckpoint = "create-folder-tree-checkpoint", InvalidateCache = "invalidate-cache", + SecretScanningV2FullScan = "secret-scanning-v2-full-scan", + SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", + SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal" } @@ -258,6 +268,19 @@ export type TQueueJobTypes = { }; }; }; + [QueueName.SecretScanningV2]: + | { + name: QueueJobs.SecretScanningV2FullScan; + payload: TQueueSecretScanningDataSourceFullScan; + } + | { + name: QueueJobs.SecretScanningV2DiffScan; + payload: TQueueSecretScanningResourceDiffScan; + } + | { + name: QueueJobs.SecretScanningV2SendNotification; + payload: TQueueSecretScanningSendNotification; + }; [QueueName.CaLifecycle]: { name: QueueJobs.CaOrderCertificateForSubscriber; payload: { @@ -271,6 +294,37 @@ export type TQueueJobTypes = { }; }; +const SECRET_SCANNING_JOBS = [ + QueueJobs.SecretScanningV2FullScan, + QueueJobs.SecretScanningV2DiffScan, + QueueJobs.SecretScanningV2SendNotification, + QueueJobs.SecretScan +]; + +const NON_STANDARD_JOBS = [...SECRET_SCANNING_JOBS]; + +const SECRET_SCANNING_QUEUES = [ + QueueName.SecretScanningV2, + QueueName.SecretFullRepoScan, + QueueName.SecretPushEventScan +]; + +const NON_STANDARD_QUEUES = [...SECRET_SCANNING_QUEUES]; + +const isQueueEnabled = (name: QueueName) => { + const appCfg = getConfig(); + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + return !NON_STANDARD_QUEUES.includes(name); + case QueueWorkerProfile.SecretScanning: + return SECRET_SCANNING_QUEUES.includes(name); + case QueueWorkerProfile.All: + default: + // allow all + return true; + } +}; + export type TQueueServiceFactory = ReturnType; export const queueServiceFactory = ( redisCfg: TRedisConfigKeys, @@ -327,7 +381,7 @@ export const queueServiceFactory = ( }); const appCfg = getConfig(); - if (appCfg.QUEUE_WORKERS_ENABLED) { + if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { workerContainer[name] = new Worker(name, jobFn, { ...queueSettings, connection @@ -346,6 +400,30 @@ export const queueServiceFactory = ( throw new Error(`${jobName} queue is already initialized`); } + const appCfg = getConfig(); + + if (!appCfg.QUEUE_WORKERS_ENABLED) return; + + switch (appCfg.QUEUE_WORKER_PROFILE) { + case QueueWorkerProfile.Standard: + if (NON_STANDARD_JOBS.includes(jobName)) { + // only process standard jobs + return; + } + + break; + case QueueWorkerProfile.SecretScanning: + if (!SECRET_SCANNING_JOBS.includes(jobName)) { + // only process secret scanning jobs + return; + } + + break; + case QueueWorkerProfile.All: + default: + // allow all + } + await pgBoss.createQueue(jobName); queueContainerPg[jobName] = true; @@ -365,7 +443,7 @@ export const queueServiceFactory = ( listener: WorkerListener[U] ) => { const appCfg = getConfig(); - if (!appCfg.QUEUE_WORKERS_ENABLED) { + if (!appCfg.QUEUE_WORKERS_ENABLED || !isQueueEnabled(name)) { return; } diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 7b4b9a99b..d3d3d3efd 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -11,7 +11,7 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => { return { errorResponseBuilder: (_, context) => { throw new RateLimitError({ - message: `Rate limit exceeded. Please try again in ${context.after}` + message: `Rate limit exceeded. Please try again in ${Math.ceil(context.ttl / 1000)} seconds` }); }, timeWindow: 60 * 1000, @@ -113,3 +113,12 @@ export const requestAccessLimit: RateLimitOptions = { max: 10, keyGenerator: (req) => req.realIp }; + +export const smtpRateLimit = ({ + keyGenerator = (req) => req.realIp +}: Pick = {}): RateLimitOptions => ({ + timeWindow: 40 * 1000, + hook: "preValidation", + max: 2, + keyGenerator +}); diff --git a/backend/src/server/plugins/secret-scanner-v2.ts b/backend/src/server/plugins/secret-scanner-v2.ts new file mode 100644 index 000000000..466450180 --- /dev/null +++ b/backend/src/server/plugins/secret-scanner-v2.ts @@ -0,0 +1,66 @@ +import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types"; +import { PushEvent } from "@octokit/webhooks-types"; +import { Probot } from "probot"; + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { writeLimit } from "@app/server/config/rateLimiter"; + +export const registerSecretScanningV2Webhooks = async (server: FastifyZodProvider) => { + const probotApp = (app: Probot) => { + app.on("installation.deleted", async (context) => { + const { payload } = context; + const { installation } = payload; + + await server.services.secretScanningV2.github.handleInstallationDeletedEvent(installation.id); + }); + + app.on("installation", async (context) => { + const { payload } = context; + logger.info({ repositories: payload.repositories }, "Installed secret scanner to"); + }); + + app.on("push", async (context) => { + const { payload } = context; + await server.services.secretScanningV2.github.handlePushEvent(payload as PushEvent); + }); + }; + + const appCfg = getConfig(); + + if (!appCfg.isSecretScanningV2Configured) { + logger.info("Secret Scanning V2 is not configured. Skipping registration of secret scanning v2 webhooks."); + return; + } + + const probot = new Probot({ + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID as string, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY as string, + secret: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET as string + }); + + await probot.load(probotApp); + + // github push event webhook + server.route({ + method: "POST", + url: "/github", + config: { + rateLimit: writeLimit + }, + handler: async (req, res) => { + const eventName = req.headers["x-github-event"] as EmitterWebhookEventName; + const signatureSHA256 = req.headers["x-hub-signature-256"] as string; + const id = req.headers["x-github-delivery"] as string; + + await probot.webhooks.verifyAndReceive({ + id, + name: eventName, + payload: JSON.stringify(req.body), + signature: signatureSHA256 + }); + + return res.send("ok"); + } + }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7b5d0411f..8fe31bce8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; import { registerV1EERoutes } from "@app/ee/routes/v1"; import { registerV2EERoutes } from "@app/ee/routes/v2"; -import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { + accessApprovalPolicyApproverDALFactory, + accessApprovalPolicyBypasserDALFactory +} from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; @@ -68,7 +71,10 @@ import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-d import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; import { scimServiceFactory } from "@app/ee/services/scim/scim-service"; -import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; +import { + secretApprovalPolicyApproverDALFactory, + secretApprovalPolicyBypasserDALFactory +} from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; @@ -87,6 +93,9 @@ import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; import { secretScanningQueueFactory } from "@app/ee/services/secret-scanning/secret-scanning-queue"; import { secretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; +import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; @@ -113,6 +122,7 @@ import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; +import { registerSecretScanningV2Webhooks } from "@app/server/plugins/secret-scanner-v2"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -214,6 +224,8 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue"; import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; +import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -313,6 +325,9 @@ export const registerRoutes = async ( ) => { const appCfg = getConfig(); await server.register(registerSecretScannerGhApp, { prefix: "/ss-webhook" }); + await server.register(registerSecretScanningV2Webhooks, { + prefix: "/secret-scanning/webhooks" + }); // db layers const userDAL = userDALFactory(db); @@ -394,9 +409,11 @@ export const registerRoutes = async ( const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalPolicyBypasserDAL = accessApprovalPolicyBypasserDALFactory(db); const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); + const sapBypasserDAL = secretApprovalPolicyBypasserDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); @@ -458,6 +475,7 @@ export const registerRoutes = async ( const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); + const secretScanningV2DAL = secretScanningV2DALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -528,6 +546,7 @@ export const registerRoutes = async ( const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, + secretApprovalPolicyBypasserDAL: sapBypasserDAL, permissionService, secretApprovalPolicyDAL, licenseService, @@ -774,12 +793,14 @@ export const registerRoutes = async ( userAliasDAL, identityTokenAuthDAL, identityAccessTokenDAL, + orgMembershipDAL, identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, orgService, keyStore, + orgDAL, licenseService, kmsService, microsoftTeamsService, @@ -838,7 +859,8 @@ export const registerRoutes = async ( const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ permissionService, projectMembershipDAL, - projectUserAdditionalPrivilegeDAL + projectUserAdditionalPrivilegeDAL, + accessApprovalRequestDAL }); const projectKeyService = projectKeyServiceFactory({ permissionService, @@ -882,6 +904,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -1268,6 +1291,7 @@ export const registerRoutes = async ( const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyBypasserDAL, groupDAL, permissionService, projectEnvDAL, @@ -1276,7 +1300,8 @@ export const registerRoutes = async ( userDAL, accessApprovalRequestDAL, additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - accessApprovalRequestReviewerDAL + accessApprovalRequestReviewerDAL, + orgMembershipDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ @@ -1807,6 +1832,21 @@ export const registerRoutes = async ( internalCaFns }); + const pkiTemplateService = pkiTemplatesServiceFactory({ + pkiTemplatesDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + projectDAL, + kmsService, + permissionService, + internalCaFns + }); + await secretRotationV2QueueServiceFactory({ secretRotationV2Service, secretRotationV2DAL, @@ -1816,6 +1856,26 @@ export const registerRoutes = async ( smtpService }); + const secretScanningV2Queue = await secretScanningV2QueueServiceFactory({ + auditLogService, + secretScanningV2DAL, + queueService, + projectDAL, + projectMembershipDAL, + smtpService, + kmsService, + keyStore + }); + + const secretScanningV2Service = secretScanningV2ServiceFactory({ + permissionService, + appConnectionService, + licenseService, + secretScanningV2DAL, + secretScanningV2Queue, + kmsService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1901,6 +1961,7 @@ export const registerRoutes = async ( pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, + pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, @@ -1931,7 +1992,8 @@ export const registerRoutes = async ( microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, githubOrgSync: githubOrgSyncConfigService, - folderCommit: folderCommitService + folderCommit: folderCommitService, + secretScanningV2: secretScanningV2Service }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 209044434..a26293ac8 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -235,11 +235,9 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, algorithm: true -}).merge( - z.object({ - metadata: ResourceMetadataSchema.optional() - }) -); +}).extend({ + metadata: ResourceMetadataSchema.optional() +}); export const SanitizedAuditLogStreamSchema = z.object({ id: z.string(), diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 8610a611b..0bade9904 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,7 +1,13 @@ import DOMPurify from "isomorphic-dompurify"; import { z } from "zod"; -import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { + IdentitiesSchema, + OrganizationsSchema, + OrgMembershipsSchema, + SuperAdminSchema, + UsersSchema +} from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -161,6 +167,129 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/organization-management/organizations", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) + }), + response: { + 200: z.object({ + organizations: OrganizationsSchema.extend({ + members: z + .object({ + user: z.object({ + id: z.string(), + email: z.string().nullish(), + username: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish() + }), + membershipId: z.string(), + role: z.string(), + roleId: z.string().nullish() + }) + .array(), + projects: z + .object({ + name: z.string(), + id: z.string(), + slug: z.string(), + createdAt: z.date() + }) + .array() + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizations = await server.services.superAdmin.getOrganizations({ + ...req.query + }); + + return { + organizations + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string(), + membershipId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.deleteOrganizationMembership( + req.params.organizationId, + req.params.membershipId, + req.permission.id, + req.permission.type + ); + + return { + organizationMembership + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organization = await server.services.superAdmin.deleteOrganization(req.params.organizationId); + + return { + organization + }; + } + }); + server.route({ method: "GET", url: "/identity-management/identities", diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 0fea749c0..f523bb218 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -33,6 +33,10 @@ import { } from "@app/services/app-connection/databricks"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { + GitHubRadarConnectionListItemSchema, + SanitizedGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema @@ -43,6 +47,7 @@ import { } from "@app/services/app-connection/humanitec"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; +import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema @@ -66,6 +71,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAppConnectionSchema = z.union([ ...SanitizedAwsConnectionSchema.options, ...SanitizedGitHubConnectionSchema.options, + ...SanitizedGitHubRadarConnectionSchema.options, ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, @@ -75,6 +81,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, + ...SanitizedMySqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options, ...SanitizedAuth0ConnectionSchema.options, ...SanitizedHCVaultConnectionSchema.options, @@ -89,6 +96,7 @@ const SanitizedAppConnectionSchema = z.union([ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AwsConnectionListItemSchema, GitHubConnectionListItemSchema, + GitHubRadarConnectionListItemSchema, GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, @@ -98,6 +106,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, + MySqlConnectionListItemSchema, CamundaConnectionListItemSchema, Auth0ConnectionListItemSchema, HCVaultConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts new file mode 100644 index 000000000..086d986b7 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/github-radar-connection-router.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubRadarConnectionSchema, + SanitizedGitHubRadarConnectionSchema, + UpdateGitHubRadarConnectionSchema +} from "@app/services/app-connection/github-radar"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubRadarConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.GitHubRadar, + server, + sanitizedResponseSchema: SanitizedGitHubRadarConnectionSchema, + createSchema: CreateGitHubRadarConnectionSchema, + updateSchema: UpdateGitHubRadarConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/repositories`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + repositories: z.object({ id: z.number(), name: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const repositories = await server.services.appConnection.githubRadar.listRepositories( + connectionId, + req.permission + ); + + return { repositories }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 1c46b4ea9..7085b3364 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -11,10 +11,12 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; +import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; +import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; @@ -27,6 +29,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.MySql, + server, + sanitizedResponseSchema: SanitizedMySqlConnectionSchema, + createSchema: CreateMySqlConnectionSchema, + updateSchema: UpdateMySqlConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index b0c186206..17f564be4 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; @@ -72,7 +73,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid body: z.object({ caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), - name: z.string().min(1).describe(CERTIFICATE_TEMPLATES.CREATE.name), + name: slugSchema().describe(CERTIFICATE_TEMPLATES.CREATE.name), commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), subjectAlternativeName: validateTemplateRegexField.describe( CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName @@ -141,7 +142,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid body: z.object({ caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), - name: z.string().min(1).optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), + name: slugSchema().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), subjectAlternativeName: validateTemplateRegexField .optional() diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index d9ef62087..5eb0c6990 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -8,6 +8,7 @@ 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 { IdentityKubernetesAuthTokenReviewMode } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick({ @@ -18,6 +19,7 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( accessTokenTrustedIps: true, createdAt: true, updatedAt: true, + tokenReviewMode: true, identityId: true, kubernetesHost: true, allowedNamespaces: true, @@ -124,6 +126,10 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .default(IdentityKubernetesAuthTokenReviewMode.Api) + .describe(KUBERNETES_AUTH.ATTACH.tokenReviewMode), allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), @@ -157,10 +163,22 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .default(0) .describe(KUBERNETES_AUTH.ATTACH.accessTokenNumUsesLimit) }) - .refine( - (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + .superRefine((data, ctx) => { + if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenTTL > data.accessTokenMaxTTL) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema @@ -247,6 +265,10 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .optional() + .describe(KUBERNETES_AUTH.UPDATE.tokenReviewMode), allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), @@ -280,10 +302,26 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .optional() .describe(KUBERNETES_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." - ), + .superRefine((data, ctx) => { + if ( + data.tokenReviewMode && + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && + !data.gatewayId + ) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenMaxTTL && data.accessTokenTTL ? data.accessTokenTTL > data.accessTokenMaxTTL : false) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema diff --git a/backend/src/server/routes/v1/invite-org-router.ts b/backend/src/server/routes/v1/invite-org-router.ts index 77ae0e627..525d51913 100644 --- a/backend/src/server/routes/v1/invite-org-router.ts +++ b/backend/src/server/routes/v1/invite-org-router.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { OrgMembershipRole, ProjectMembershipRole, UsersSchema } from "@app/db/schemas"; -import { inviteUserRateLimit } from "@app/server/config/rateLimiter"; +import { inviteUserRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; @@ -11,7 +11,7 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit() }, method: "POST", schema: { @@ -81,7 +81,10 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { server.route({ url: "/signup-resend", config: { - rateLimit: inviteUserRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => + (req.body as { membershipId?: string })?.membershipId?.trim().substring(0, 100) ?? req.realIp + }) }, method: "POST", schema: { diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts index cc0543d4c..d4b1ee188 100644 --- a/backend/src/server/routes/v1/org-admin-router.ts +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -47,7 +47,9 @@ export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/projects/:projectId/grant-admin-access", config: { - rateLimit: writeLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { params: z.object({ diff --git a/backend/src/server/routes/v1/password-router.ts b/backend/src/server/routes/v1/password-router.ts index 724468e02..eeb730f29 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -2,10 +2,10 @@ import { z } from "zod"; import { BackupPrivateKeySchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { validateSignUpAuthorization } from "@app/services/auth/auth-fns"; -import { AuthMode } from "@app/services/auth/auth-type"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { UserEncryption } from "@app/services/user/user-types"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { @@ -80,7 +80,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-reset", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ @@ -224,7 +226,9 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/email/password-setup", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.auth.actor === ActorType.USER ? req.auth.userId : req.realIp) + }) }, schema: { response: { @@ -233,6 +237,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { await server.services.password.sendPasswordSetupEmail(req.permission); @@ -267,6 +272,7 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { }) } }, + onRequest: verifyAuth([AuthMode.JWT]), handler: async (req, res) => { await server.services.password.setupPassword(req.body, req.permission); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 651faede4..2a868864e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -160,7 +160,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .default("false") .transform((value) => value === "true"), type: z - .enum([ProjectType.SecretManager, ProjectType.KMS, ProjectType.CertificateManager, ProjectType.SSH, "all"]) + .enum([ + ProjectType.SecretManager, + ProjectType.KMS, + ProjectType.CertificateManager, + ProjectType.SSH, + ProjectType.SecretScanning, + "all" + ]) .optional() }), response: { @@ -173,7 +180,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaces = await server.services.project.getProjects({ includeRoles: req.query.includeRoles, diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index fb055877d..93c422d15 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -5,6 +5,7 @@ import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerServiceTokenRouter } from "./service-token-router"; @@ -15,7 +16,15 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); - await server.register(registerCaRouter, { prefix: "/pki/ca" }); + + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/pki" } + ); + await server.register( async (orgRouter) => { await orgRouter.register(registerOrgRouter); diff --git a/backend/src/server/routes/v2/pki-templates-router.ts b/backend/src/server/routes/v2/pki-templates-router.ts new file mode 100644 index 000000000..e481af0a2 --- /dev/null +++ b/backend/src/server/routes/v2/pki-templates-router.ts @@ -0,0 +1,309 @@ +import { z } from "zod"; + +import { CertificateTemplatesSchema } from "@app/db/schemas"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +export const registerPkiTemplatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: z.object({ + name: slugSchema(), + caName: slugSchema({ field: "caName" }), + projectId: z.string(), + commonName: validateTemplateRegexField, + subjectAlternativeName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.createTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + name: slugSchema().optional(), + caName: slugSchema(), + projectId: z.string(), + commonName: validateTemplateRegexField.optional(), + subjectAlternativeName: validateTemplateRegexField.optional(), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional(), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([]) + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.updateTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:templateName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: z.string().min(1) + }), + body: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.deleteTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.body.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/:templateName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateTemplate: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.pkiTemplate.getTemplateByName({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + projectId: req.query.projectId + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string(), + limit: z.coerce.number().default(100), + offset: z.coerce.number().default(0) + }), + response: { + 200: z.object({ + certificateTemplates: CertificateTemplatesSchema.extend({ + ca: z.object({ id: z.string(), name: z.string() }) + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates, totalCount } = await server.services.pkiTemplate.listTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { certificateTemplates, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + commonName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsage).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: validateAltNamesField + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.issueCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/:templateName/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + templateName: slugSchema() + }), + body: z.object({ + projectId: z.string(), + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"), + csr: z.string().trim().min(1).max(4096) + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.pkiTemplate.signCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + templateName: req.params.templateName, + ...req.body + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 027f527fc..bbd566334 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, smtpRateLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; import { sanitizedOrganizationSchema } from "@app/services/org/org-schema"; @@ -12,7 +12,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/me/emails/code", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { username?: string })?.username?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index 552253cde..c249e7dbe 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { ForbiddenRequestError } from "@app/lib/errors"; -import { authRateLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter"; import { GenericResourceNameSchema } from "@app/server/lib/schemas"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -13,7 +13,9 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { url: "/email/signup", method: "POST", config: { - rateLimit: authRateLimit + rateLimit: smtpRateLimit({ + keyGenerator: (req) => (req.body as { email?: string })?.email?.trim().substring(0, 100) ?? req.realIp + }) }, schema: { body: z.object({ diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 25c6394fa..227818bf0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -1,5 +1,6 @@ export enum AppConnection { GitHub = "github", + GitHubRadar = "github-radar", AWS = "aws", Databricks = "databricks", GCP = "gcp", @@ -11,6 +12,7 @@ export enum AppConnection { Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", + MySql = "mysql", Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 86e728008..4597d8f45 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -52,6 +52,11 @@ import { } from "./databricks"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; +import { + getGitHubRadarConnectionListItem, + GitHubRadarConnectionMethod, + validateGitHubRadarConnectionCredentials +} from "./github-radar"; import { getHCVaultConnectionListItem, HCVaultConnectionMethod, @@ -64,6 +69,8 @@ import { } from "./humanitec"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; +import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; +import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getTeamCityConnectionListItem, @@ -87,6 +94,7 @@ export const listAppConnectionOptions = () => { return [ getAwsConnectionListItem(), getGitHubConnectionListItem(), + getGitHubRadarConnectionListItem(), getGcpConnectionListItem(), getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), @@ -96,6 +104,7 @@ export const listAppConnectionOptions = () => { getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem(), + getMySqlConnectionListItem(), getCamundaConnectionListItem(), getAzureClientSecretsConnectionListItem(), getWindmillConnectionListItem(), @@ -157,6 +166,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitHubRadar]: validateGitHubRadarConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureAppConfiguration]: @@ -166,6 +176,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.MySql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator, @@ -184,6 +195,7 @@ export const validateAppConnectionCredentials = async ( export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { switch (method) { case GitHubConnectionMethod.App: + case GitHubRadarConnectionMethod.App: return "GitHub App"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: @@ -208,6 +220,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: + case MySqlConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -253,12 +266,14 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AWS]: platformManagedCredentialsNotSupported, [AppConnection.Databricks]: platformManagedCredentialsNotSupported, [AppConnection.GitHub]: platformManagedCredentialsNotSupported, + [AppConnection.GitHubRadar]: platformManagedCredentialsNotSupported, [AppConnection.GCP]: platformManagedCredentialsNotSupported, [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.MySql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported, [AppConnection.Camunda]: platformManagedCredentialsNotSupported, [AppConnection.Vercel]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index ddd0b1087..0042fdf42 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -3,6 +3,7 @@ import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AWS]: "AWS", [AppConnection.GitHub]: "GitHub", + [AppConnection.GitHubRadar]: "GitHub Radar", [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", @@ -13,6 +14,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", + [AppConnection.MySql]: "MySQL", [AppConnection.Camunda]: "Camunda", [AppConnection.Windmill]: "Windmill", [AppConnection.Auth0]: "Auth0", @@ -26,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { export const APP_CONNECTION_PLAN_MAP: Record = { [AppConnection.AWS]: AppConnectionPlanType.Regular, [AppConnection.GitHub]: AppConnectionPlanType.Regular, + [AppConnection.GitHubRadar]: AppConnectionPlanType.Regular, [AppConnection.GCP]: AppConnectionPlanType.Regular, [AppConnection.AzureKeyVault]: AppConnectionPlanType.Regular, [AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular, @@ -43,5 +46,6 @@ export const APP_CONNECTION_PLAN_MAP: Record = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, + [AppConnection.GitHubRadar]: ValidateGitHubRadarConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, [AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema, [AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema, @@ -86,6 +90,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record>>; -export type TSqlConnection = TPostgresConnection | TMsSqlConnection; +export type TSqlConnection = TPostgresConnection | TMsSqlConnection | TMySqlConnection; export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput | TGitHubConnectionInput + | TGitHubRadarConnectionInput | TGcpConnectionInput | TAzureKeyVaultConnectionInput | TAzureAppConfigurationConnectionInput @@ -157,6 +167,7 @@ export type TAppConnectionInput = { id: string } & ( | TVercelConnectionInput | TPostgresConnectionInput | TMsSqlConnectionInput + | TMySqlConnectionInput | TCamundaConnectionInput | TAzureClientSecretsConnectionInput | TWindmillConnectionInput @@ -168,7 +179,7 @@ export type TAppConnectionInput = { id: string } & ( | TOnePassConnectionInput ); -export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; +export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput | TMySqlConnectionInput; export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -182,6 +193,7 @@ export type TUpdateAppConnectionDTO = Partial { + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG } = getConfig(); + + return { + name: "GitHub Radar" as const, + app: AppConnection.GitHubRadar as const, + methods: Object.values(GitHubRadarConnectionMethod) as [GitHubRadarConnectionMethod.App], + appClientSlug: INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG + }; +}; + +export const getGitHubRadarClient = (appConnection: TGitHubRadarConnection) => { + const appCfg = getConfig(); + + const { method, credentials } = appConnection; + + let client: Octokit; + + switch (method) { + case GitHubRadarConnectionMethod.App: + if (!appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID || !appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + client = new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_ID, + privateKey: appCfg.INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY, + installationId: credentials.installationId + } + }); + break; + default: + throw new InternalServerError({ + message: `Unhandled GitHub Radar connection method: ${method as GitHubRadarConnectionMethod}` + }); + } + + return client; +}; + +export const listGitHubRadarRepositories = async (appConnection: TGitHubRadarConnection) => { + const client = getGitHubRadarClient(appConnection); + + const repositories: TGitHubRadarRepository[] = await client.paginate("GET /installation/repositories"); + + return repositories; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; + error?: string; +}; + +export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRadarConnectionConfig) => { + const { credentials, method } = config; + + const { INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, SITE_URL } = + getConfig(); + + if (!INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID || !INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET) { + throw new InternalServerError({ + message: `GitHub ${getAppConnectionMethodName(method).replace( + "GitHub", + "" + )} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID, + client_secret: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET, + code: credentials.code, + redirect_uri: `${SITE_URL}/organization/app-connections/github-radar/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubRadarConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + type: string; + id: number; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + if (!tokenResp.data.access_token) { + throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` }); + } + + switch (method) { + case GitHubRadarConnectionMethod.App: + return { + installationId: credentials.installationId + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubRadarConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts new file mode 100644 index 000000000..ebdfa45e1 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; + +export const GitHubRadarConnectionInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "GitHub Radar App code required"), + installationId: z.string().min(1, "GitHub Radar App Installation ID required") +}); + +export const GitHubRadarConnectionOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const ValidateGitHubRadarConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(GitHubRadarConnectionMethod.App) + .describe(AppConnections.CREATE(AppConnection.GitHubRadar).method), + credentials: GitHubRadarConnectionInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHubRadar).credentials + ) + }) +]); + +export const CreateGitHubRadarConnectionSchema = ValidateGitHubRadarConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHubRadar) +); + +export const UpdateGitHubRadarConnectionSchema = z + .object({ + credentials: GitHubRadarConnectionInputCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.GitHubRadar).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHubRadar)); + +const BaseGitHubRadarConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHubRadar) }); + +export const GitHubRadarConnectionSchema = BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema +}); + +export const SanitizedGitHubRadarConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubRadarConnectionSchema.extend({ + method: z.literal(GitHubRadarConnectionMethod.App), + credentials: GitHubRadarConnectionOutputCredentialsSchema.pick({}) + }) +]); + +export const GitHubRadarConnectionListItemSchema = z.object({ + name: z.literal("GitHub Radar"), + app: z.literal(AppConnection.GitHubRadar), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubRadarConnectionMethod).array(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts new file mode 100644 index 000000000..583c43952 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-service.ts @@ -0,0 +1,24 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { listGitHubRadarRepositories } from "@app/services/app-connection/github-radar/github-radar-connection-fns"; +import { TGitHubRadarConnection } from "@app/services/app-connection/github-radar/github-radar-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const githubRadarConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listRepositories = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.GitHubRadar, connectionId, actor); + + const repositories = await listGitHubRadarRepositories(appConnection); + + return repositories.map((repo) => ({ id: repo.id, name: repo.full_name })); + }; + + return { + listRepositories + }; +}; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts new file mode 100644 index 000000000..c9e7e5aa8 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-types.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubRadarConnectionSchema, + GitHubRadarConnectionSchema, + ValidateGitHubRadarConnectionCredentialsSchema +} from "./github-radar-connection-schemas"; + +export type TGitHubRadarConnection = z.infer; + +export type TGitHubRadarConnectionInput = z.infer & { + app: AppConnection.GitHubRadar; +}; + +export type TValidateGitHubRadarConnectionCredentialsSchema = typeof ValidateGitHubRadarConnectionCredentialsSchema; + +export type TGitHubRadarConnectionConfig = DiscriminativePick< + TGitHubRadarConnectionInput, + "method" | "app" | "credentials" +>; + +export type TGitHubRadarRepository = { + id: number; + full_name: string; +}; diff --git a/backend/src/services/app-connection/github-radar/index.ts b/backend/src/services/app-connection/github-radar/index.ts new file mode 100644 index 000000000..3f2a5f663 --- /dev/null +++ b/backend/src/services/app-connection/github-radar/index.ts @@ -0,0 +1,4 @@ +export * from "./github-radar-connection-enums"; +export * from "./github-radar-connection-fns"; +export * from "./github-radar-connection-schemas"; +export * from "./github-radar-connection-types"; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index c4c94b4fc..134b9667b 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -13,7 +13,12 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; export const LdapConnectionSimpleBindCredentialsSchema = z.object({ provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider), - url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url), + url: z + .string() + .trim() + .min(1, "URL required") + .refine((value) => LdapUrlRegex.test(value), "Invalid LDAP URL") + .describe(AppConnections.CREDENTIALS.LDAP.url), dn: z .string() .trim() diff --git a/backend/src/services/app-connection/mysql/index.ts b/backend/src/services/app-connection/mysql/index.ts new file mode 100644 index 000000000..68c4d4c02 --- /dev/null +++ b/backend/src/services/app-connection/mysql/index.ts @@ -0,0 +1,4 @@ +export * from "./mysql-connection-enums"; +export * from "./mysql-connection-fns"; +export * from "./mysql-connection-schemas"; +export * from "./mysql-connection-types"; diff --git a/backend/src/services/app-connection/mysql/mysql-connection-enums.ts b/backend/src/services/app-connection/mysql/mysql-connection-enums.ts new file mode 100644 index 000000000..e46fd9ba5 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-enums.ts @@ -0,0 +1,3 @@ +export enum MySqlConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/mysql/mysql-connection-fns.ts b/backend/src/services/app-connection/mysql/mysql-connection-fns.ts new file mode 100644 index 000000000..c74037257 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { MySqlConnectionMethod } from "./mysql-connection-enums"; + +export const getMySqlConnectionListItem = () => { + return { + name: "MySQL" as const, + app: AppConnection.MySql as const, + methods: Object.values(MySqlConnectionMethod) as [MySqlConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts new file mode 100644 index 000000000..082bac557 --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -0,0 +1,66 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { MySqlConnectionMethod } from "./mysql-connection-enums"; + +export const MySqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BaseMySqlConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.MySql) }); + +export const MySqlConnectionSchema = BaseMySqlConnectionSchema.extend({ + method: z.literal(MySqlConnectionMethod.UsernameAndPassword), + credentials: MySqlConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedMySqlConnectionSchema = z.discriminatedUnion("method", [ + BaseMySqlConnectionSchema.extend({ + method: z.literal(MySqlConnectionMethod.UsernameAndPassword), + credentials: MySqlConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateMySqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(MySqlConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.MySql).method), + credentials: MySqlConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.MySql).credentials + ) + }) +]); + +export const CreateMySqlConnectionSchema = ValidateMySqlConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true }) +); + +export const UpdateMySqlConnectionSchema = z + .object({ + credentials: MySqlConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.MySql).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MySql, { supportsPlatformManagedCredentials: true })); + +export const MySqlConnectionListItemSchema = z.object({ + name: z.literal("MySQL"), + app: z.literal(AppConnection.MySql), + methods: z.nativeEnum(MySqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/mysql/mysql-connection-types.ts b/backend/src/services/app-connection/mysql/mysql-connection-types.ts new file mode 100644 index 000000000..0d8c0f6be --- /dev/null +++ b/backend/src/services/app-connection/mysql/mysql-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateMySqlConnectionSchema, + MySqlConnectionSchema, + ValidateMySqlConnectionCredentialsSchema +} from "./mysql-connection-schemas"; + +export type TMySqlConnection = z.infer; + +export type TMySqlConnectionInput = z.infer & { + app: AppConnection.MySql; +}; + +export type TValidateMySqlConnectionCredentialsSchema = typeof ValidateMySqlConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts index bc98e9bcc..7df1929ba 100644 --- a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -15,7 +15,8 @@ const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const SQL_CONNECTION_CLIENT_MAP = { [AppConnection.Postgres]: "pg", - [AppConnection.MsSql]: "mssql" + [AppConnection.MsSql]: "mssql", + [AppConnection.MySql]: "mysql2" }; const getConnectionConfig = ({ @@ -45,6 +46,17 @@ const getConnectionConfig = ({ : { encrypt: false } }; } + case AppConnection.MySql: { + return { + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host + } + : false + }; + } default: throw new Error(`Unhandled SQL Connection Config: ${app as AppConnection}`); } @@ -101,7 +113,8 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding] > = { [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]], - [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]] + [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]], + [AppConnection.MySql]: ({ username, password }) => [`ALTER USER ??@'%' IDENTIFIED BY '${password}';`, [username]] }; export const transferSqlConnectionCredentialsToPlatform = async ( diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index fa66758c3..a57c085ba 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -311,7 +311,6 @@ export const certificateAuthorityServiceFactory = ({ } const updatedCa = await internalCertificateAuthorityService.updateCaById({ - ...configuration, isInternal: true, enableDirectIssuance, caId: certificateAuthority.id, diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 457863121..def2e2bed 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -1,8 +1,10 @@ +/* eslint-disable no-bitwise */ import * as x509 from "@peculiar/x509"; import { KeyObject } from "crypto"; +import RE2 from "re2"; import { z } from "zod"; -import { TPkiSubscribers } from "@app/db/schemas"; +import { TCertificateTemplates, TPkiSubscribers } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; @@ -31,6 +33,7 @@ import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; +import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { certificateAuthorityDAL: Pick; @@ -257,7 +260,274 @@ export const InternalCertificateAuthorityFns = ({ }; }; + const issueCertificateWithTemplate = async ( + ca: Awaited>, + certificateTemplate: TCertificateTemplates, + { altNames, commonName, ttl, extendedKeyUsages, keyUsages, notAfter, notBefore }: TIssueCertWithTemplateDTO + ) => { + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const commonNameRegex = new RE2(certificateTemplate.commonName); + if (!commonNameRegex.test(commonName)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (notAfterDate.getTime() - notBeforeDate.getTime() > ms(certificateTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const subjectAlternativeNameRegex = new RE2(certificateTemplate.subjectAlternativeName); + altNames.split(",").forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const appCfg = getConfig(); + + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; + if (keyUsages === undefined && !certificateTemplate) { + selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + } + + if (keyUsages === undefined && certificateTemplate) { + selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[]; + } + + if (keyUsages?.length && certificateTemplate) { + const validKeyUsages = certificateTemplate.keyUsages || []; + if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + selectedKeyUsages = keyUsages; + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + + // handle extended key usages + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? []; + if (extendedKeyUsages === undefined && certificateTemplate) { + selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[]; + } + + if (extendedKeyUsages?.length && certificateTemplate) { + const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || []; + if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on template policy" + }); + } + selectedExtendedKeyUsages = extendedKeyUsages; + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + + let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + + if (altNames) { + altNamesArray = altNames.split(",").map((altName) => { + if (z.string().email().safeParse(altName).success) { + return { type: "email", value: altName }; + } + + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: caCert.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId: ca.projectId, + certificateTemplateId: certificateTemplate.id + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: certificateChainPem, + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca, + template: certificateTemplate + }; + }; + return { - issueCertificate + issueCertificate, + issueCertificateWithTemplate }; }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts index ffec0d762..1cf9a8597 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts @@ -55,8 +55,4 @@ export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificate configuration: InternalCertificateAuthorityConfigurationSchema }); -export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( - CaType.INTERNAL -).extend({ - configuration: InternalCertificateAuthorityConfigurationSchema.optional() -}); +export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.INTERNAL); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 8c16ef3c2..083117241 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -1,5 +1,5 @@ /* eslint-disable no-bitwise */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import slugify from "@sindresorhus/slugify"; import crypto, { KeyObject } from "crypto"; @@ -16,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; @@ -1952,15 +1953,15 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const certificateTemplates = await certificateTemplateDAL.find({ caId }); return { - certificateTemplates, + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), ca: expandInternalCa(ca) }; }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index f8ea82a59..fadd7b88d 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -221,3 +221,13 @@ export type TOrderCertificateForSubscriberDTO = { subscriberId: string; caType: CaType; }; + +export type TIssueCertWithTemplateDTO = { + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +}; diff --git a/backend/src/services/certificate-template/certificate-template-schema.ts b/backend/src/services/certificate-template/certificate-template-schema.ts index 7a87daddf..6ab39af7d 100644 --- a/backend/src/services/certificate-template/certificate-template-schema.ts +++ b/backend/src/services/certificate-template/certificate-template-schema.ts @@ -18,3 +18,20 @@ export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ caName: z.string() }) ); + +export const sanitizedCertificateTemplateV2 = CertificateTemplatesSchema.pick({ + id: true, + caId: true, + name: true, + commonName: true, + subjectAlternativeName: true, + pkiCollectionId: true, + ttl: true, + keyUsages: true, + extendedKeyUsages: true +}).merge( + z.object({ + projectId: z.string(), + caName: z.string() + }) +); diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 04bf76f5c..be1200503 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -1,11 +1,14 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import bcrypt from "bcrypt"; import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -78,8 +81,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) ); return certificateTemplateDAL.transaction(async (tx) => { @@ -140,8 +143,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); if (caId) { @@ -153,6 +156,13 @@ export const certificateTemplateServiceFactory = ({ } } + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + } + return certificateTemplateDAL.transaction(async (tx) => { await certificateTemplateDAL.updateById( certTemplate.id, @@ -198,8 +208,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); await certificateTemplateDAL.deleteById(certTemplate.id); @@ -225,8 +235,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); return certTemplate; @@ -267,8 +277,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const appCfg = getConfig(); @@ -350,8 +360,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); const originalCaEstConfig = await certificateTemplateEstConfigDAL.findOne({ @@ -430,8 +440,8 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateTemplates + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index a3ec1bdeb..df72b428e 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import axios, { AxiosError } from "axios"; import https from "https"; import jwt from "jsonwebtoken"; +import RE2 from "re2"; import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/schemas"; import { TGatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; @@ -19,8 +20,9 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -32,6 +34,7 @@ import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/su import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { + IdentityKubernetesAuthTokenReviewMode, TAttachKubernetesAuthDTO, TCreateTokenReviewResponse, TGetKubernetesAuthDTO, @@ -71,19 +74,25 @@ export const identityKubernetesAuthServiceFactory = ({ gatewayId: string; targetHost: string; targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; }, - gatewayCallback: (host: string, port: number) => Promise + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); const callbackResult = await withGatewayProxy( - async (port) => { - // Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server" - const res = await gatewayCallback("https://localhost", port); + async (port, httpsAgent) => { + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); return res; }, { + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, targetHost: inputs.targetHost, targetPort: inputs.targetPort, relayHost, @@ -94,7 +103,12 @@ export const identityKubernetesAuthServiceFactory = ({ ca: relayDetails.certChain, cert: relayDetails.certificate, key: relayDetails.privateKey.toString() - } + }, + // we always pass this, because its needed for both tcp and http protocol + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: Boolean(inputs.caCert) + }) } ); @@ -128,17 +142,29 @@ export const identityKubernetesAuthServiceFactory = ({ caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); } - let tokenReviewerJwt = ""; - if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { - tokenReviewerJwt = decryptor({ - cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt - }).toString(); - } else { - // if no token reviewer is provided means the incoming token has to act as reviewer - tokenReviewerJwt = serviceAccountJwt; - } + const tokenReviewCallbackRaw = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); + let tokenReviewerJwt = ""; + if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { + tokenReviewerJwt = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt + }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; + } + + let servername = identityKubernetesAuth.kubernetesHost; + if (servername.startsWith("https://") || servername.startsWith("http://")) { + servername = new RE2("^https?:\\/\\/").replace(servername, ""); + } + + // get the last colon index, if it has a port, remove it, including the colon + const lastColonIndex = servername.lastIndexOf(":"); + if (lastColonIndex !== -1) { + servername = servername.substring(0, lastColonIndex); + } - const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { const baseUrl = port ? `${host}:${port}` : host; const res = await axios @@ -159,10 +185,10 @@ export const identityKubernetesAuthServiceFactory = ({ }, signal: AbortSignal.timeout(10000), timeout: 10000, - // if ca cert, rejectUnauthorized: true httpsAgent: new https.Agent({ ca: caCert, - rejectUnauthorized: !!caCert + rejectUnauthorized: Boolean(caCert), + servername }) } ) @@ -185,18 +211,119 @@ export const identityKubernetesAuthServiceFactory = ({ return res.data; }; - const [k8sHost, k8sPort] = identityKubernetesAuth.kubernetesHost.split(":"); + const tokenReviewCallbackThroughGateway = async ( + host: string = identityKubernetesAuth.kubernetesHost, + port?: number, + httpsAgent?: https.Agent + ) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); - const data = identityKubernetesAuth.gatewayId - ? await $gatewayProxyWrapper( + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, { - gatewayId: identityKubernetesAuth.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443 + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) + } }, - tokenReviewCallback + { + headers: { + "Content-Type": "application/json", + "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken + }, + signal: AbortSignal.timeout(10000), + timeout: 10000, + ...(httpsAgent ? { httpsAgent } : {}) + } ) - : await tokenReviewCallback(); + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + let { message } = err?.response?.data as unknown as { message?: string }; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + return res.data; + }; + + let data: TCreateTokenReviewResponse | undefined; + + if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const { kubernetesHost } = identityKubernetesAuth; + const lastColonIndex = kubernetesHost.lastIndexOf(":"); + const k8sHost = kubernetesHost.substring(0, lastColonIndex); + const k8sPort = kubernetesHost.substring(lastColonIndex + 1); + + if (!identityKubernetesAuth.gatewayId) { + throw new BadRequestError({ + message: "Gateway ID is required when token review mode is set to Gateway" + }); + } + + data = await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: k8sHost, // note(daniel): must include the protocol (https|http) + targetPort: k8sPort ? Number(k8sPort) : 443, + caCert, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + let { kubernetesHost } = identityKubernetesAuth; + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); + + data = identityKubernetesAuth.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); + } else { + throw new BadRequestError({ + message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` + }); + } + + if (!data) { + throw new BadRequestError({ + message: "Failed to review token" + }); + } if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); @@ -291,6 +418,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -377,6 +505,7 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityMembershipOrg.identityId, kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -403,6 +532,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -485,6 +615,7 @@ export const identityKubernetesAuthServiceFactory = ({ const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index 7a9cb88b5..03dd7fd77 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -5,11 +5,17 @@ export type TLoginKubernetesAuthDTO = { jwt: string; }; +export enum IdentityKubernetesAuthTokenReviewMode { + Api = "api", + Gateway = "gateway" +} + export type TAttachKubernetesAuthDTO = { identityId: string; kubernetesHost: string; caCert: string; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -26,6 +32,7 @@ export type TUpdateKubernetesAuthDTO = { kubernetesHost?: string; caCert?: string; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; @@ -63,6 +70,18 @@ export type TCreateTokenReviewResponse = { status: TCreateTokenReviewSuccessResponse | TCreateTokenReviewErrorResponse; }; +export type TKubernetesTokenRequest = { + apiVersion: "authentication.k8s.io/v1"; + kind: "TokenRequest"; + spec: { + audiences: string[]; + expirationSeconds: number; + }; + status: { + token: string; + }; +}; + export type TRevokeKubernetesAuthDTO = { identityId: string; } & Omit; diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index 3c8bc5d37..4928fd178 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -412,7 +412,15 @@ export const identityProjectDALFactory = (db: TDbClient) => { } ] }); - return members; + + return members.map((el) => ({ + ...el, + roles: el.roles.sort((a, b) => { + const roleA = (a.customRoleName || a.role).toLowerCase(); + const roleB = (b.customRoleName || b.role).toLowerCase(); + return roleA.localeCompare(roleB); + }) + })); } catch (error) { throw new DatabaseError({ error, name: "FindByProjectId" }); } diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index 62767200c..5f9e25f29 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -196,17 +196,20 @@ export const orgAdminServiceFactory = ({ .filter( (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId ) - .map((el) => el.user.email!); + .map((el) => el.user.email!) + .filter(Boolean); - await smtpService.sendMail({ - template: SmtpTemplates.OrgAdminProjectDirectAccess, - recipients: filteredProjectMembers, - subjectLine: "Organization Admin Project Direct Access Issued", - substitutions: { - projectName: project.name, - email: projectMembers.find((el) => el.userId === actorId)?.user?.username - } - }); + if (filteredProjectMembers.length) { + await smtpService.sendMail({ + template: SmtpTemplates.OrgAdminProjectDirectAccess, + recipients: filteredProjectMembers, + subjectLine: "Organization Admin Project Direct Access Issued", + substitutions: { + projectName: project.name, + email: projectMembers.find((el) => el.userId === actorId)?.user?.username + } + }); + } return { isExistingMember: false, membership: updatedMembership }; }; diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 32cabf444..c4f0856a1 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + OrganizationsSchema, OrgMembershipRole, TableName, TOrganizations, @@ -12,7 +13,15 @@ import { TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { + buildFindFilter, + ormify, + selectAllTableCols, + sqlNestRelationships, + TFindFilter, + TFindOpt, + withTransaction +} from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; import { OrgAuthMethod } from "./org-types"; @@ -22,6 +31,110 @@ export type TOrgDALFactory = ReturnType; export const orgDALFactory = (db: TDbClient) => { const orgOrm = ormify(db, TableName.Organization); + const findOrganizationsByFilter = async ({ + limit, + offset, + searchTerm, + sortBy + }: { + limit: number; + offset: number; + searchTerm: string; + sortBy?: keyof TOrganizations; + }) => { + try { + const query = db.replicaNode()(TableName.Organization); + + // Build the subquery for limited organization IDs + const orgSubquery = db.replicaNode().select("id").from(TableName.Organization); + + if (searchTerm) { + void orgSubquery.where((qb) => { + void qb.whereILike(`${TableName.Organization}.name`, `%${searchTerm}%`); + }); + } + + if (sortBy) { + void orgSubquery.orderBy(sortBy); + } + + void orgSubquery.limit(limit).offset(offset); + + // Main query with joins, limited to the subquery results + const docs = await query + .whereIn(`${TableName.Organization}.id`, orgSubquery) + .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + .leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .where((qb) => { + void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`); + }) + .select(selectAllTableCols(TableName.Organization)) + .select(db.ref("name").withSchema(TableName.Project).as("projectName")) + .select(db.ref("id").withSchema(TableName.Project).as("projectId")) + .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug")) + .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) + .select(db.ref("email").withSchema(TableName.Users).as("userEmail")) + .select(db.ref("username").withSchema(TableName.Users).as("username")) + .select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) + .select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) + .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) + .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) + .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => OrganizationsSchema.parse(data), + childrenMapper: [ + { + key: "projectId", + label: "projects" as const, + mapper: ({ projectId, projectName, projectSlug, projectCreatedAt }) => ({ + id: projectId, + name: projectName, + slug: projectSlug, + createdAt: projectCreatedAt + }) + }, + { + key: "userId", + label: "members" as const, + mapper: ({ + userId, + userEmail, + username, + firstName, + lastName, + orgMembershipId, + orgMembershipRole, + orgMembershipRoleName, + orgMembershipRoleId + }) => ({ + user: { + id: userId, + email: userEmail, + username, + firstName, + lastName + }, + membershipId: orgMembershipId, + role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name + roleId: orgMembershipRoleId + }) + } + ] + }); + + return formattedDocs; + } catch (error) { + throw new DatabaseError({ error, name: "Find organizations by filter" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db @@ -507,6 +620,7 @@ export const orgDALFactory = (db: TDbClient) => { findOrgById, findOrgBySlug, findAllOrgsByUserId, + findOrganizationsByFilter, ghostUserExists, findOrgMembersByUsername, findOrgMembersByRole, diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 795371c76..5bedbd60c 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -137,6 +137,15 @@ export const pkiSubscriberServiceFactory = ({ } } + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + if (ca.projectId !== projectId) { + throw new BadRequestError({ message: "CA does not belong to the project" }); + } + const newSubscriber = await pkiSubscriberDAL.create({ caId, projectId, @@ -245,6 +254,17 @@ export const pkiSubscriberServiceFactory = ({ } } + if (caId) { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); + } + + if (ca.projectId !== projectId) { + throw new BadRequestError({ message: "CA does not belong to the project" }); + } + } + const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, { caId, name, diff --git a/backend/src/services/pki-templates/pki-templates-dal.ts b/backend/src/services/pki-templates/pki-templates-dal.ts new file mode 100644 index 000000000..45c632d70 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-dal.ts @@ -0,0 +1,102 @@ +import { Knex } from "knex"; +import { Tables } from "knex/types/tables"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, TFindReturn } from "@app/lib/knex"; + +export type TPkiTemplatesDALFactory = ReturnType; + +export const pkiTemplatesDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.CertificateTemplate); + + const findOne = async ( + filter: Partial, + tx?: Knex + ) => { + try { + const { projectId, ...templateFilters } = filter; + const res = await (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .first(); + + if (!res) return undefined; + + return { ...res, ca: { id: res.caId, name: res.caName } }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + const find = async < + TCount extends boolean = false, + TCountDistinct extends keyof Tables[TableName.CertificateTemplate]["base"] | undefined = undefined + >( + filter: TFindFilter & { projectId: string }, + { + offset, + limit, + sort, + count, + tx, + countDistinct + }: TFindOpt = {} + ) => { + try { + const { projectId, ...templateFilters } = filter; + + const query = (tx || db.replicaNode())(TableName.CertificateTemplate) + .join( + TableName.CertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.CertificateTemplate}.caId` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter(templateFilters, TableName.CertificateTemplate)) + .where((qb) => { + if (projectId) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority)); + } + }) + .select(selectAllTableCols(TableName.CertificateTemplate)) + .select(db.ref("projectId").withSchema(TableName.CertificateAuthority)) + .select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName")); + + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + } + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = (await query) as TFindReturn; + return res.map((el) => ({ ...el, ca: { id: el.caId, name: el.caName } })); + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, find, findOne }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-service.ts b/backend/src/services/pki-templates/pki-templates-service.ts new file mode 100644 index 000000000..97f910d6e --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-service.ts @@ -0,0 +1,644 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; + +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaStatus } from "../certificate-authority/certificate-authority-enums"; +import { + createSerialNumber, + expandInternalCa, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg, + parseDistinguishedName +} from "../certificate-authority/certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority/certificate-authority-secret-dal"; +import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TPkiTemplatesDALFactory } from "./pki-templates-dal"; +import { + TCreatePkiTemplateDTO, + TDeletePkiTemplateDTO, + TGetPkiTemplateDTO, + TIssueCertPkiTemplateDTO, + TListPkiTemplateDTO, + TSignCertPkiTemplateDTO, + TUpdatePkiTemplateDTO +} from "./pki-templates-types"; + +type TPkiTemplatesServiceFactoryDep = { + pkiTemplatesDAL: TPkiTemplatesDALFactory; + permissionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + | "findByIdWithAssociatedCa" + | "findById" + | "transaction" + | "create" + | "updateById" + | "findWithAssociatedCa" + | "findOne" + >; + internalCaFns: ReturnType; + kmsService: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find" + >; + certificateSecretDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; +}; + +export type TPkiTemplatesServiceFactory = ReturnType; + +export const pkiTemplatesServiceFactory = ({ + pkiTemplatesDAL, + permissionService, + internalCaFns, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + kmsService, + projectDAL +}: TPkiTemplatesServiceFactoryDep) => { + const createTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + caName, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl, + projectId + }: TCreatePkiTemplateDTO) => { + const ca = await certificateAuthorityDAL.findOne({ name: caName, projectId }); + if (!ca) { + throw new NotFoundError({ + message: `CA with name ${caName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + + const existingTemplate = await pkiTemplatesDAL.findOne({ name, projectId: ca.projectId }); + if (existingTemplate) { + throw new BadRequestError({ message: `Template with name ${name} already exists.` }); + } + + const newTemplate = await pkiTemplatesDAL.create({ + caId: ca.id, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return newTemplate; + }; + + const updateTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + caName, + commonName, + extendedKeyUsages, + keyUsages, + name, + subjectAlternativeName, + ttl, + projectId + }: TUpdatePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + let caId; + if (caName) { + const ca = await certificateAuthorityDAL.findOne({ name: caName, projectId }); + if (!ca || ca.projectId !== certTemplate.projectId) { + throw new NotFoundError({ + message: `CA with name ${caName} not found` + }); + } + caId = ca.id; + } + + if (name) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + subject(ProjectPermissionSub.CertificateTemplates, { name }) + ); + + const existingTemplate = await pkiTemplatesDAL.findOne({ name, projectId }); + if (existingTemplate && existingTemplate.id !== certTemplate.id) { + throw new BadRequestError({ message: `Template with name ${name} already exists.` }); + } + } + + const updatedTemplate = await pkiTemplatesDAL.updateById(certTemplate.id, { + caId, + name, + commonName, + subjectAlternativeName, + ttl, + keyUsages, + extendedKeyUsages + }); + return updatedTemplate; + }; + + const deleteTemplate = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TDeletePkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const deletedTemplate = await pkiTemplatesDAL.deleteById(certTemplate.id); + return deletedTemplate; + }; + + const getTemplateByName = async ({ + templateName, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: TGetPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + return certTemplate; + }; + + const listTemplate = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + limit, + offset + }: TListPkiTemplateDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const certTemplate = await pkiTemplatesDAL.find({ projectId }, { limit, offset, count: true }); + return { + certificateTemplates: certTemplate.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ), + totalCount: Number(certTemplate?.[0]?.count ?? 0) + }; + }; + + const issueCertificate = async ({ + templateName, + projectId, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId, + keyUsages, + extendedKeyUsages + }: TIssueCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (ca.internalCa?.id) { + return internalCaFns.issueCertificateWithTemplate(ca, certTemplate, { + altNames, + commonName, + ttl, + extendedKeyUsages, + keyUsages, + notAfter, + notBefore + }); + } + + throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); + }; + + const signCertificate = async ({ + templateName, + csr, + projectId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + ttl + }: TSignCertPkiTemplateDTO) => { + const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId }); + if (!certTemplate) { + throw new NotFoundError({ + message: `Certificate template with name ${templateName} not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.IssueCert, + subject(ProjectPermissionSub.CertificateTemplates, { name: templateName }) + ); + + const appCfg = getConfig(); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId); + if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${certTemplate.caId}' not found` }); + + if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); + if (!ca.internalCa?.activeCaCertId) + throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const notBeforeDate = new Date(); + const notAfterDate = new Date(new Date().getTime() + ms(ttl ?? "0")); + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const dn = parseDistinguishedName(csrObj.subject); + const cn = dn.commonName; + if (!cn) + throw new BadRequestError({ + message: "Missing common name on CSR" + }); + + const commonNameRegex = new RE2(certTemplate.commonName); + if (!commonNameRegex.test(cn)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (ms(ttl) > ms(certTemplate.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } + + const { caPrivateKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + + const extensions: x509.Extension[] = [ + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey), + new x509.CRLDistributionPointsExtension([distributionPointUrl]), + new x509.AuthorityInfoAccessExtension({ + caIssuers: new x509.GeneralName("url", caIssuerUrl) + }), + new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy + ]; + + // handle key usages + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension | undefined; // Better to type as optional + let selectedKeyUsages: CertKeyUsage[] = []; + if (csrKeyUsageExtension && csrKeyUsageExtension.usages) { + selectedKeyUsages = Object.values(CertKeyUsage).filter( + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + const validKeyUsages = certTemplate.keyUsages || []; + if (selectedKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) { + throw new BadRequestError({ + message: "Invalid key usage value based on template policy" + }); + } + + const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0); + if (keyUsagesBitValue) { + extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); + } + } + + // handle extended key usage + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension | undefined; + let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = []; + if (csrExtendedKeyUsageExtension && csrExtendedKeyUsageExtension.usages.length > 0) { + selectedExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + + if (selectedExtendedKeyUsages.some((eku) => !certTemplate?.extendedKeyUsages?.includes(eku))) { + throw new BadRequestError({ + message: "Invalid extended key usage value based on subscriber's specified extended key usages" + }); + } + + if (selectedExtendedKeyUsages.length) { + extensions.push( + new x509.ExtendedKeyUsageExtension( + selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]), + true + ) + ); + } + } + + // attempt to read from CSR if altNames is not explicitly provided + let altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = []; + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + + altNamesArray = sanNames.items + .filter((value) => value.type === "email" || value.type === "dns") + .map((name) => ({ + type: name.type as "email" | "dns", + value: name.value + })); + } + + if (altNamesArray.length) { + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const subjectAlternativeNameRegex = new RE2(certTemplate.subjectAlternativeName); + altNamesArray.forEach((altName) => { + if (!subjectAlternativeNameRegex.test(altName.value)) { + throw new BadRequestError({ + message: "Invalid subject alternative name based on template policy" + }); + } + }); + + const serialNumber = createSerialNumber(); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + caCertId: caCert.id, + status: CertStatus.ACTIVE, + friendlyName: cn, + commonName: cn, + altNames: altNamesArray.map((el) => el.value).join(","), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + keyUsages: selectedKeyUsages, + extendedKeyUsages: selectedExtendedKeyUsages, + projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca: expandInternalCa(ca), + commonName: cn, + template: certTemplate + }; + }; + + return { + createTemplate, + updateTemplate, + getTemplateByName, + listTemplate, + deleteTemplate, + signCertificate, + issueCertificate + }; +}; diff --git a/backend/src/services/pki-templates/pki-templates-types.ts b/backend/src/services/pki-templates/pki-templates-types.ts new file mode 100644 index 000000000..8dd18c8a9 --- /dev/null +++ b/backend/src/services/pki-templates/pki-templates-types.ts @@ -0,0 +1,53 @@ +import { TProjectPermission } from "@app/lib/types"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; + +export type TCreatePkiTemplateDTO = { + caName: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TUpdatePkiTemplateDTO = { + templateName: string; + caName?: string; + name?: string; + commonName?: string; + subjectAlternativeName?: string; + ttl?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TListPkiTemplateDTO = { + limit?: number; + offset?: number; +} & TProjectPermission; + +export type TGetPkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TDeletePkiTemplateDTO = { + templateName: string; +} & TProjectPermission; + +export type TIssueCertPkiTemplateDTO = { + templateName: string; + commonName: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; +} & TProjectPermission; + +export type TSignCertPkiTemplateDTO = { + templateName: string; + csr: string; + ttl: string; +} & TProjectPermission; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 1e71f4605..8315ed429 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -92,7 +92,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("name").as("projectName").withSchema(TableName.Project) ) - .where({ isGhost: false }); + .where({ isGhost: false }) + .orderBy(`${TableName.Users}.username` as "username"); const members = sqlNestRelationships({ data: docs, @@ -149,7 +150,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } ] }); - return members; + return members.map((el) => ({ + ...el, + roles: el.roles.sort((a, b) => { + const roleA = (a.customRoleName || a.role).toLowerCase(); + const roleB = (b.customRoleName || b.role).toLowerCase(); + return roleA.localeCompare(roleB); + }) + })); } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); } diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 832077a56..7f733503c 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -22,6 +22,56 @@ export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { const projectOrm = ormify(db, TableName.Project); + const findIdentityProjects = async (identityId: string, orgId: string, projectType: ProjectType | "all") => { + try { + const workspaces = await db(TableName.IdentityProjectMembership) + .where({ identityId }) + .join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + if (projectType !== "all") { + void qb.where(`${TableName.Project}.type`, projectType); + } + }) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + selectAllTableCols(TableName.Project), + db.ref("id").withSchema(TableName.Project).as("_id"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy([ + { column: `${TableName.Project}.name`, order: "asc" }, + { column: `${TableName.Environment}.position`, order: "asc" } + ]); + + const nestedWorkspaces = sqlNestRelationships({ + data: workspaces, + key: "id", + parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), + childrenMapper: [ + { + key: "envId", + label: "environments" as const, + mapper: ({ envId: id, envSlug: slug, envName: name }) => ({ + id, + slug, + name + }) + } + ] + }); + + return nestedWorkspaces.map((workspace) => ({ + ...workspace, + organization: workspace.orgId + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find identity projects" }); + } + }; + const findUserProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { try { const workspaces = await db @@ -453,6 +503,7 @@ export const projectDALFactory = (db: TDbClient) => { return { ...projectOrm, findUserProjects, + findIdentityProjects, setProjectUpgradeStatus, findAllProjectsByIdentity, findProjectGhostUser, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index d042ee32a..1ca5eb754 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -17,6 +17,7 @@ import { ProjectPermissionActions, ProjectPermissionCertificateActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSshHostActions, ProjectPermissionSub @@ -29,7 +30,7 @@ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh- import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; -import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -258,16 +259,17 @@ export const projectServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); - const plan = await licenseService.getPlan(organization.id); - if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { - // case: limit imposed on number of workspaces allowed - // case: number of workspaces used exceeds the number of workspaces allowed - throw new BadRequestError({ - message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." - }); - } - const results = await (trx || projectDAL).transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]); + + const plan = await licenseService.getPlan(organization.id); + if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { + // case: limit imposed on number of workspaces allowed + // case: number of workspaces used exceeds the number of workspaces allowed + throw new BadRequestError({ + message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." + }); + } const ghostUser = await orgService.addGhostUser(organization.id, tx); if (kmsKeyId) { @@ -572,12 +574,16 @@ export const projectServiceFactory = ({ const getProjects = async ({ actorId, + actor, includeRoles, actorAuthMethod, actorOrgId, type = ProjectType.SecretManager }: TListProjectsDTO) => { - const workspaces = await projectDAL.findUserProjects(actorId, actorOrgId, type); + const workspaces = + actor === ActorType.IDENTITY + ? await projectDAL.findIdentityProjects(actorId, actorOrgId, type) + : await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { const { permission } = await permissionService.getUserOrgPermission( @@ -1131,15 +1137,15 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId); return { - certificateTemplates + certificateTemplates: certificateTemplates.filter((el) => + permission.can( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { name: el.name }) + ) + ) }; }; diff --git a/backend/src/services/secret-sync/1password/1password-sync-fns.ts b/backend/src/services/secret-sync/1password/1password-sync-fns.ts index c832fbbdb..9305f2e3c 100644 --- a/backend/src/services/secret-sync/1password/1password-sync-fns.ts +++ b/backend/src/services/secret-sync/1password/1password-sync-fns.ts @@ -127,6 +127,7 @@ export const OnePassSyncFns = { syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { vaultId } } = secretSync; @@ -164,7 +165,7 @@ export const OnePassSyncFns = { for await (const [key, variable] of Object.entries(items)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index a73bc81c9..b687d81dd 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -294,7 +294,7 @@ const deleteParametersBatch = async ( export const AwsParameterStoreSyncFns = { syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); @@ -391,7 +391,7 @@ export const AwsParameterStoreSyncFns = { const [key, parameter] = entry; // eslint-disable-next-line no-continue - if (!matchesSchema(key, syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", syncOptions.keySchema)) continue; if (!(key in secretMap) || !secretMap[key].value) { parametersToDelete.push(parameter); diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index 1b1daf2ac..df73512e5 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -57,7 +57,11 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string): Promise => { +const getSecretsRecord = async ( + client: SecretsManagerClient, + environment: string, + keySchema?: string +): Promise => { const awsSecretsRecord: TAwsSecretsRecord = {}; let hasNext = true; let nextToken: string | undefined; @@ -72,7 +76,7 @@ const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string if (output.SecretList) { output.SecretList.forEach((secretEntry) => { - if (secretEntry.Name && matchesSchema(secretEntry.Name, keySchema)) { + if (secretEntry.Name && matchesSchema(secretEntry.Name, environment, keySchema)) { awsSecretsRecord[secretEntry.Name] = secretEntry; } }); @@ -307,11 +311,11 @@ const processTags = ({ export const AwsSecretsManagerSyncFns = { syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); @@ -401,7 +405,7 @@ export const AwsSecretsManagerSyncFns = { for await (const secretKey of Object.keys(awsSecretsRecord)) { // eslint-disable-next-line no-continue - if (!matchesSchema(secretKey, syncOptions.keySchema)) continue; + if (!matchesSchema(secretKey, environment?.slug || "", syncOptions.keySchema)) continue; if (!(secretKey in secretMap) || !secretMap[secretKey].value) { try { @@ -468,7 +472,11 @@ export const AwsSecretsManagerSyncFns = { getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise => { const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, secretSync.syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord( + client, + secretSync.environment?.slug || "", + secretSync.syncOptions.keySchema + ); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); const { destinationConfig } = secretSync; @@ -503,11 +511,11 @@ export const AwsSecretsManagerSyncFns = { } }, removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { for await (const secretKey of Object.keys(awsSecretsRecord)) { diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index dce509fac..7aa1c16ce 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -141,7 +141,7 @@ export const azureAppConfigurationSyncFactory = ({ for await (const key of Object.keys(azureAppConfigSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; const azureSecret = azureAppConfigSecrets[key]; if ( diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index fd1e2bd78..edc8af709 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -194,7 +194,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur for await (const deleteSecretKey of deleteSecrets.filter( (secret) => - matchesSchema(secret, secretSync.syncOptions.keySchema) && + matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) && !setSecrets.find((setSecret) => setSecret.key === secret) )) { await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, { diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts index 256ae4644..516efae10 100644 --- a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts +++ b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts @@ -118,7 +118,7 @@ export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSec for await (const secret of Object.keys(camundaSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(secret, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(secret in secretMap) || !secretMap[secret].value) { try { diff --git a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts index 11143e24d..175901323 100644 --- a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts +++ b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts @@ -117,7 +117,7 @@ export const databricksSyncFactory = ({ kmsService, appConnectionDAL }: TDatabri for await (const secret of databricksSecretKeys) { // eslint-disable-next-line no-continue - if (!matchesSchema(secret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(secret.key in secretMap)) { await deleteDatabricksSecrets({ diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts index 97da66a48..6a45aab31 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts @@ -155,7 +155,7 @@ export const GcpSyncFns = { for await (const key of Object.keys(gcpSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; try { if (!(key in secretMap) || !secretMap[key].value) { diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 952f4b512..f06f0cfc2 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -223,8 +223,9 @@ export const GithubSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const encryptedSecret of encryptedSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(encryptedSecret.name, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!(encryptedSecret.name in secretMap)) { await deleteSecret(client, secretSync, encryptedSecret); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 6331cd91f..724eec7be 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -68,6 +68,7 @@ export const HCVaultSyncFns = { syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { mount, path }, syncOptions: { disableSecretDeletion, keySchema } } = secretSync; @@ -97,7 +98,7 @@ export const HCVaultSyncFns = { for await (const [key] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; if (!(key in secretMap)) { delete variables[key]; diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts index d0f2a9f65..f9096ac71 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts @@ -16,12 +16,14 @@ const HCVaultSyncDestinationConfigSchema = z.object({ .string() .trim() .min(1, "Secrets Engine Mount required") + .max(128) .describe(SecretSyncs.DESTINATION_CONFIG.HC_VAULT.mount), path: z .string() .trim() .min(1, "Path required") - .transform((val) => val.replace(/^\/+|\/+$/g, "")) // removes leading/trailing slashes + .max(128) + .transform((val) => new RE2("^/+|/+$", "g").replace(val, "")) // removes leading/trailing slashes .refine((val) => new RE2("^([a-zA-Z0-9._-]+/)*[a-zA-Z0-9._-]+$").test(val), { message: "Invalid Vault path format. Use alphanumerics, dots, dashes, underscores, and single slashes between segments." diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts index 2fcf488aa..ccb6ac2bc 100644 --- a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts @@ -200,8 +200,9 @@ export const HumanitecSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const humanitecSecret of humanitecSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(humanitecSecret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(humanitecSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!secretMap[humanitecSecret.key]) { await deleteSecret(secretSync, humanitecSecret); diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index dbf3a3699..ba3a79c50 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,5 +1,5 @@ import { AxiosError } from "axios"; -import RE2 from "re2"; +import handlebars from "handlebars"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; @@ -68,13 +68,17 @@ type TSyncSecretDeps = { }; // Add schema to secret keys -const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => { +const addSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { if (!schema) return unprocessedSecretMap; const processedSecretMap: TSecretMap = {}; for (const [key, value] of Object.entries(unprocessedSecretMap)) { - const newKey = new RE2("{{secretKey}}").replace(schema, key); + const newKey = handlebars.compile(schema)({ + secretKey: key, + environment + }); + processedSecretMap[newKey] = value; } @@ -82,10 +86,17 @@ const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMa }; // Strip schema from secret keys -const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => { +const stripSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { if (!schema) return unprocessedSecretMap; - const [prefix, suffix] = schema.split("{{secretKey}}"); + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; const strippedMap: TSecretMap = {}; @@ -103,21 +114,40 @@ const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecret }; // Checks if a key matches a schema -export const matchesSchema = (key: string, schema?: string): boolean => { +export const matchesSchema = (key: string, environment: string, schema?: string): boolean => { if (!schema) return true; - const [prefix, suffix] = schema.split("{{secretKey}}"); - if (prefix === undefined || suffix === undefined) return true; + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); - return key.startsWith(prefix) && key.endsWith(suffix); + // This edge-case shouldn't be possible + if (!compiledSchemaPattern.includes("{{secretKey}}")) { + return key === compiledSchemaPattern; + } + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + if (prefix === "" && suffix === "") return true; + + // If prefix is empty, key must end with suffix + if (prefix === "") return key.endsWith(suffix); + + // If suffix is empty, key must start with prefix + if (suffix === "") return key.startsWith(prefix); + + return key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length; }; // Filter only for secrets with keys that match the schema -const filterForSchema = (secretMap: TSecretMap, schema?: string): TSecretMap => { +const filterForSchema = (secretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { const filteredMap: TSecretMap = {}; for (const [key, value] of Object.entries(secretMap)) { - if (matchesSchema(key, schema)) { + if (matchesSchema(key, environment, schema)) { filteredMap[key] = value; } } @@ -131,7 +161,7 @@ export const SecretSyncFns = { secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: @@ -255,14 +285,16 @@ export const SecretSyncFns = { ); } - return stripSchema(filterForSchema(secretMap), secretSync.syncOptions.keySchema); + const filtered = filterForSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + const stripped = stripSchema(filtered, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + return stripped; }, removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts index 80e96bf8b..3622ef3d0 100644 --- a/backend/src/services/secret-sync/secret-sync-schemas.ts +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -28,10 +28,30 @@ const BaseSyncOptionsSchema = ({ keySchema: z .string() .optional() - .refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/).test(val), { - message: - "Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder." - }) + .refine( + (val) => { + if (!val) return true; + + const allowedOptionalPlaceholders = ["{{environment}}"]; + + const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`); + const contentIsValid = allowedContentRegex.test(val); + + // Check if {{secretKey}} is present + const secretKeyRegex = new RE2(/\{\{secretKey\}\}/); + const secretKeyIsPresent = secretKeyRegex.test(val); + + return contentIsValid && secretKeyIsPresent; + }, + { + message: + "Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) .describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema), disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion) }); diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts index 0afe29beb..28ef2d0d3 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -127,7 +127,7 @@ export const TeamCitySyncFns = { for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts index a58ec213c..cb546ba63 100644 --- a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -232,8 +232,11 @@ export const TerraformCloudSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for (const terraformCloudVariable of terraformCloudVariables) { - // eslint-disable-next-line no-continue - if (!matchesSchema(terraformCloudVariable.key, secretSync.syncOptions.keySchema)) continue; + if ( + !matchesSchema(terraformCloudVariable.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) + ) + // eslint-disable-next-line no-continue + continue; if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { await deleteVariable(secretSync, terraformCloudVariable); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts index 90e9327e5..b5ea98265 100644 --- a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -291,8 +291,9 @@ export const VercelSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const vercelSecret of vercelSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(vercelSecret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(vercelSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!secretMap[vercelSecret.key]) { await deleteSecret(secretSync, vercelSecret); diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts index a09706581..b5e11c957 100644 --- a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts +++ b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts @@ -128,6 +128,7 @@ export const WindmillSyncFns = { syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { path }, syncOptions: { disableSecretDeletion, keySchema } } = secretSync; @@ -171,7 +172,7 @@ export const WindmillSyncFns = { for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx index 3d02fc793..01bf779c5 100644 --- a/backend/src/services/smtp/emails/BaseEmailWrapper.tsx +++ b/backend/src/services/smtp/emails/BaseEmailWrapper.tsx @@ -13,7 +13,7 @@ export const BaseEmailWrapper = ({ title, preview, children, siteUrl }: BaseEmai - + {preview}

diff --git a/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx new file mode 100644 index 000000000..2e212cb82 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningScanFailedTemplate.tsx @@ -0,0 +1,67 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningScanFailedTemplateProps extends Omit { + dataSourceName: string; + resourceName: string; + projectName: string; + timestamp: string; + url: string; + errorMessage: string; +} + +export const SecretScanningScanFailedTemplate = ({ + dataSourceName, + resourceName, + projectName, + siteUrl, + errorMessage, + url, + timestamp +}: SecretScanningScanFailedTemplateProps) => { + return ( + + + Infisical encountered an error while attempting to scan the resource {resourceName} + +
+ Resource + {resourceName} + Data Source + {dataSourceName} + Project + {projectName} + Timestamp + {timestamp} + Error + {errorMessage} +
+
+ +
+
+ ); +}; + +export default SecretScanningScanFailedTemplate; + +SecretScanningScanFailedTemplate.PreviewProps = { + dataSourceName: "my-data-source", + resourceName: "my-resource", + projectName: "my-project", + timestamp: "May 3rd 2025, 5:42 pm", + url: "https://infisical.com", + errorMessage: "401 Unauthorized", + siteUrl: "https://infisical.com" +} as SecretScanningScanFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx new file mode 100644 index 000000000..b7c0d8a14 --- /dev/null +++ b/backend/src/services/smtp/emails/SecretScanningSecretsDetectedTemplate.tsx @@ -0,0 +1,101 @@ +import { Button, Heading, Link, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface SecretScanningSecretsDetectedTemplateProps + extends Omit { + numberOfSecrets: number; + isDiffScan: boolean; + authorName?: string; + authorEmail?: string; + resourceName: string; + url: string; +} + +export const SecretScanningSecretsDetectedTemplate = ({ + numberOfSecrets, + siteUrl, + authorName, + authorEmail, + isDiffScan, + resourceName, + url +}: SecretScanningSecretsDetectedTemplateProps) => { + return ( + + + Infisical has uncovered {numberOfSecrets} secret(s) + {isDiffScan ? " from a recent commit to" : " in"} {resourceName} + +
+ + You are receiving this notification because one or more leaked secrets have been detected + {isDiffScan && " in a recent commit"} + {isDiffScan ? ( + (authorName || authorEmail) && ( + <> + {" "} + pushed by {authorName ?? "Unknown Pusher"}{" "} + {authorEmail && ( + <> + ( + + {authorEmail} + + ) + + )} + + ) + ) : ( + <> + {" "} + in your resource {resourceName} + + )} + . + + + If these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as + a comment in the given programming language. This will prevent future notifications from being sent out for + these secrets. + + + If these are production secrets, please rotate them immediately. + + + Once you have taken action, be sure to update the finding status in the{" "} + + Infisical Dashboard + + . + +
+
+ +
+
+ ); +}; + +export default SecretScanningSecretsDetectedTemplate; + +SecretScanningSecretsDetectedTemplate.PreviewProps = { + authorName: "Jim", + authorEmail: "jim@infisical.com", + resourceName: "my-resource", + numberOfSecrets: 3, + url: "https://infisical.com", + isDiffScan: true, + siteUrl: "https://infisical.com" +} as SecretScanningSecretsDetectedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 29738dbb2..840a98cad 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -21,6 +21,8 @@ export * from "./SecretLeakIncidentTemplate"; export * from "./SecretReminderTemplate"; export * from "./SecretRequestCompletedTemplate"; export * from "./SecretRotationFailedTemplate"; +export * from "./SecretScanningScanFailedTemplate"; +export * from "./SecretScanningSecretsDetectedTemplate"; export * from "./SecretSyncFailedTemplate"; export * from "./ServiceTokenExpiryNoticeTemplate"; export * from "./SignupEmailVerificationTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 12b38ebb2..ac56f0ee4 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -30,6 +30,8 @@ import { SecretReminderTemplate, SecretRequestCompletedTemplate, SecretRotationFailedTemplate, + SecretScanningScanFailedTemplate, + SecretScanningSecretsDetectedTemplate, SecretSyncFailedTemplate, ServiceTokenExpiryNoticeTemplate, SignupEmailVerificationTemplate, @@ -73,7 +75,9 @@ export enum SmtpTemplates { ProjectAccessRequest = "projectAccess", OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess", OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", - ServiceTokenExpired = "serviceTokenExpired" + ServiceTokenExpired = "serviceTokenExpired", + SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", + SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected" } export enum SmtpHost { @@ -113,7 +117,9 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, [SmtpTemplates.ResetPassword]: PasswordResetTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, - [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate + [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, + [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, + [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 04dfa253b..ff319796e 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -11,7 +11,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; -import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthMethod, 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"; @@ -21,7 +21,9 @@ import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; +import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; @@ -33,7 +35,8 @@ import { TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, - TAdminSignUpDTO + TAdminSignUpDTO, + TGetOrganizationsDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -41,6 +44,8 @@ type TSuperAdminServiceFactoryDep = { identityTokenAuthDAL: TIdentityTokenAuthDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -73,6 +78,8 @@ export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, identityDAL, + orgDAL, + orgMembershipDAL, userAliasDAL, authService, orgService, @@ -521,6 +528,47 @@ export const superAdminServiceFactory = ({ return updatedUser; }; + const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => { + const organizations = await orgDAL.findOrganizationsByFilter({ + offset, + searchTerm, + sortBy: "name", + limit + }); + return organizations; + }; + + const deleteOrganization = async (organizationId: string) => { + const organization = await orgDAL.deleteById(organizationId); + return organization; + }; + + const deleteOrganizationMembership = async ( + organizationId: string, + membershipId: string, + actorId: string, + actorType: ActorType + ) => { + if (actorType === ActorType.USER) { + const orgMembership = await orgMembershipDAL.findById(membershipId); + if (!orgMembership) { + throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); + } + + if (orgMembership.userId === actorId) { + throw new BadRequestError({ + message: "You cannot remove yourself from the organization from the instance management panel." + }); + } + } + + const [organizationMembership] = await orgMembershipDAL.delete({ + orgId: organizationId, + id: membershipId + }); + return organizationMembership; + }; + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const identities = await identityDAL.getIdentitiesByFilter({ limit, @@ -663,6 +711,9 @@ export const superAdminServiceFactory = ({ deleteIdentitySuperAdminAccess, deleteUserSuperAdminAccess, invalidateCache, - checkIfInvalidatingCache + checkIfInvalidatingCache, + getOrganizations, + deleteOrganization, + deleteOrganizationMembership }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index c804bed74..22803a650 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -35,6 +35,12 @@ export type TAdminGetIdentitiesDTO = { searchTerm: string; }; +export type TGetOrganizationsDTO = { + offset: number; + limit: number; + searchTerm: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/cli/go.mod b/cli/go.mod index a2b256f8a..fc7322f61 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -5,6 +5,8 @@ go 1.23.0 toolchain go1.23.5 require ( + github.com/BobuSumisu/aho-corasick v1.0.3 + github.com/Masterminds/sprig/v3 v3.3.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/creack/pty v1.1.21 @@ -12,17 +14,17 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.9.1 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.92 + github.com/infisical/go-sdk v0.5.95 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 - github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 github.com/pion/dtls/v3 v3.0.4 github.com/pion/logging v0.2.3 github.com/pion/turn/v4 v4.0.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a github.com/quic-go/quic-go v0.50.0 github.com/rs/cors v1.11.0 @@ -30,7 +32,9 @@ require ( github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.10.0 + github.com/wasilibs/go-re2 v1.10.0 golang.org/x/crypto v0.36.0 + golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 golang.org/x/sys v0.31.0 golang.org/x/term v0.30.0 gopkg.in/yaml.v2 v2.4.0 @@ -43,10 +47,8 @@ require ( cloud.google.com/go/compute/metadata v0.4.0 // indirect cloud.google.com/go/iam v1.1.11 // indirect dario.cat/mergo v1.0.1 // indirect - github.com/BobuSumisu/aho-corasick v1.0.3 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect @@ -65,7 +67,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dvsekhvalnov/jose2go v1.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect @@ -105,13 +107,15 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect @@ -122,7 +126,6 @@ require ( go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect go.uber.org/mock v0.5.0 // indirect - golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect golang.org/x/mod v0.23.0 // indirect golang.org/x/net v0.35.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index cb1b1c1cf..aa8dc1f61 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -127,8 +127,9 @@ github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= @@ -147,11 +148,11 @@ github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg= github.com/fatih/semgroup v1.2.0/go.mod h1:1KAD4iIYfXjE4U13B48VM4z9QUwV5Tt8O4rS879kgm8= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gitleaks/go-gitdiff v0.8.0 h1:7aExTZm+K/M/EQKOyYcub8rIAdWK6ONxPGuRzxmWW+0= -github.com/gitleaks/go-gitdiff v0.8.0/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= github.com/gitleaks/go-gitdiff v0.9.1 h1:ni6z6/3i9ODT685OLCTf+s/ERlWUNWQF4x1pvoNICw0= github.com/gitleaks/go-gitdiff v0.9.1/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF1uXV9ZyNvrA= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -293,6 +294,10 @@ github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7P github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.94 h1:wKBj+KpJEe+ZzOJ7koXQZDR0dLL9bt0Kqgf/1q+7tG4= +github.com/infisical/go-sdk v0.5.94/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.95 h1:so0YwPofbT7j6Ao8Xcxee/o3ia33meuEVDU2vWr9yfs= +github.com/infisical/go-sdk v0.5.95/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= @@ -307,6 +312,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -373,8 +380,6 @@ github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlR github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 h1:lL+y4Xv20pVlCGyLzNHRC0I0rIHhIL1lTvHizoS/dU8= -github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI= @@ -385,12 +390,15 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a h1:Ey0XWvrg6u6hyIn1Kd/jCCmL+bMv9El81tvuGBbxZGg= github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a/go.mod h1:oa2sAs9tGai3VldabTV0eWejt/O4/OOD7azP8GaikqU= @@ -402,6 +410,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -420,7 +430,6 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= @@ -451,9 +460,15 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ0= +github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= @@ -661,6 +676,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index ec92f2ad2..83732b64a 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -12,6 +12,35 @@ import ( const USER_AGENT = "cli" +const ( + operationCallGetRawSecretsV3 = "CallGetRawSecretsV3" + operationCallGetEncryptedWorkspaceKey = "CallGetEncryptedWorkspaceKey" + operationCallGetServiceTokenDetails = "CallGetServiceTokenDetails" + operationCallLogin1V3 = "CallLogin1V3" + operationCallVerifyMfaToken = "CallVerifyMfaToken" + operationCallLogin2V3 = "CallLogin2V3" + operationCallGetAllOrganizations = "CallGetAllOrganizations" + operationCallSelectOrganization = "CallSelectOrganization" + operationCallGetAllWorkSpacesUserBelongsTo = "CallGetAllWorkSpacesUserBelongsTo" + operationCallGetProjectById = "CallGetProjectById" + operationCallIsAuthenticated = "CallIsAuthenticated" + operationCallGetNewAccessTokenWithRefreshToken = "CallGetNewAccessTokenWithRefreshToken" + operationCallGetFoldersV1 = "CallGetFoldersV1" + operationCallCreateFolderV1 = "CallCreateFolderV1" + operationCallDeleteFolderV1 = "CallDeleteFolderV1" + operationCallDeleteSecretsV3 = "CallDeleteSecretsV3" + operationCallCreateServiceToken = "CallCreateServiceToken" + operationCallUniversalAuthLogin = "CallUniversalAuthLogin" + operationCallMachineIdentityRefreshAccessToken = "CallMachineIdentityRefreshAccessToken" + operationCallFetchSingleSecretByName = "CallFetchSingleSecretByName" + operationCallCreateRawSecretsV3 = "CallCreateRawSecretsV3" + operationCallUpdateRawSecretsV3 = "CallUpdateRawSecretsV3" + operationCallRegisterGatewayIdentityV1 = "CallRegisterGatewayIdentityV1" + operationCallExchangeRelayCertV1 = "CallExchangeRelayCertV1" + operationCallGatewayHeartBeatV1 = "CallGatewayHeartBeatV1" + operationCallBootstrapInstance = "CallBootstrapInstance" +) + func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncryptedWorkspaceKeyRequest) (GetEncryptedWorkspaceKeyResponse, error) { endpoint := fmt.Sprintf("%v/v2/workspace/%v/encrypted-key", config.INFISICAL_URL, request.WorkspaceId) var result GetEncryptedWorkspaceKeyResponse @@ -22,11 +51,11 @@ func CallGetEncryptedWorkspaceKey(httpClient *resty.Client, request GetEncrypted Get(endpoint) if err != nil { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unable to complete api request [err=%s]", err) + return GetEncryptedWorkspaceKeyResponse{}, NewGenericRequestError(operationCallGetEncryptedWorkspaceKey, err) } if response.IsError() { - return GetEncryptedWorkspaceKeyResponse{}, fmt.Errorf("CallGetEncryptedWorkspaceKey: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + return GetEncryptedWorkspaceKeyResponse{}, NewAPIErrorWithResponse(operationCallGetEncryptedWorkspaceKey, response, nil) } return result, nil @@ -41,11 +70,11 @@ func CallGetServiceTokenDetailsV2(httpClient *resty.Client) (GetServiceTokenDeta Get(fmt.Sprintf("%v/v2/service-token", config.INFISICAL_URL)) if err != nil { - return GetServiceTokenDetailsResponse{}, fmt.Errorf("CallGetServiceTokenDetails: Unable to complete api request [err=%s]", err) + return GetServiceTokenDetailsResponse{}, NewGenericRequestError(operationCallGetServiceTokenDetails, err) } if response.IsError() { - return GetServiceTokenDetailsResponse{}, fmt.Errorf("CallGetServiceTokenDetails: Unsuccessful response: [response=%s]", response) + return GetServiceTokenDetailsResponse{}, NewAPIErrorWithResponse(operationCallGetServiceTokenDetails, response, nil) } return tokenDetailsResponse, nil @@ -61,11 +90,11 @@ func CallLogin1V2(httpClient *resty.Client, request GetLoginOneV2Request) (GetLo Post(fmt.Sprintf("%v/v3/auth/login1", config.INFISICAL_URL)) if err != nil { - return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V3: Unable to complete api request [err=%s]", err) + return GetLoginOneV2Response{}, NewGenericRequestError(operationCallLogin1V3, err) } if response.IsError() { - return GetLoginOneV2Response{}, fmt.Errorf("CallLogin1V3: Unsuccessful response: [response=%s]", response) + return GetLoginOneV2Response{}, NewAPIErrorWithResponse(operationCallLogin1V3, response, nil) } return loginOneV2Response, nil @@ -99,7 +128,7 @@ func CallVerifyMfaToken(httpClient *resty.Client, request VerifyMfaTokenRequest) } if err != nil { - return nil, nil, fmt.Errorf("CallVerifyMfaToken: Unable to complete api request [err=%s]", err) + return nil, nil, NewGenericRequestError(operationCallVerifyMfaToken, err) } if response.IsError() { @@ -135,11 +164,11 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo } if err != nil { - return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V3: Unable to complete api request [err=%s]", err) + return GetLoginTwoV2Response{}, NewGenericRequestError(operationCallLogin2V3, err) } if response.IsError() { - return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V3: Unsuccessful response: [response=%s]", response) + return GetLoginTwoV2Response{}, NewAPIErrorWithResponse(operationCallLogin2V3, response, nil) } return loginTwoV2Response, nil @@ -154,11 +183,11 @@ func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse Get(fmt.Sprintf("%v/v1/organization", config.INFISICAL_URL)) if err != nil { - return GetOrganizationsResponse{}, err + return GetOrganizationsResponse{}, NewGenericRequestError(operationCallGetAllOrganizations, err) } if response.IsError() { - return GetOrganizationsResponse{}, fmt.Errorf("CallGetAllOrganizations: Unsuccessful response: [response=%v]", response) + return GetOrganizationsResponse{}, NewAPIErrorWithResponse(operationCallGetAllOrganizations, response, nil) } return orgResponse, nil @@ -175,11 +204,11 @@ func CallSelectOrganization(httpClient *resty.Client, request SelectOrganization Post(fmt.Sprintf("%v/v3/auth/select-organization", config.INFISICAL_URL)) if err != nil { - return SelectOrganizationResponse{}, err + return SelectOrganizationResponse{}, NewGenericRequestError(operationCallSelectOrganization, err) } if response.IsError() { - return SelectOrganizationResponse{}, fmt.Errorf("CallSelectOrganization: Unsuccessful response: [response=%v]", response) + return SelectOrganizationResponse{}, NewAPIErrorWithResponse(operationCallSelectOrganization, response, nil) } return selectOrgResponse, nil @@ -214,11 +243,11 @@ func CallGetProjectById(httpClient *resty.Client, id string) (Project, error) { Get(fmt.Sprintf("%v/v1/workspace/%s", config.INFISICAL_URL, id)) if err != nil { - return Project{}, err + return Project{}, NewGenericRequestError(operationCallGetProjectById, err) } if response.IsError() { - return Project{}, fmt.Errorf("CallGetProjectById: Unsuccessful response: [response=%v]", response) + return Project{}, NewAPIErrorWithResponse(operationCallGetProjectById, response, nil) } return projectResponse.Project, nil @@ -237,7 +266,7 @@ func CallIsAuthenticated(httpClient *resty.Client) bool { } if response.IsError() { - log.Debug().Msgf("CallIsAuthenticated: Unsuccessful response: [response=%v]", response) + log.Debug().Msgf("%s: Unsuccessful response: [response=%v]", operationCallIsAuthenticated, response) return false } @@ -257,11 +286,11 @@ func CallGetNewAccessTokenWithRefreshToken(httpClient *resty.Client, refreshToke Post(fmt.Sprintf("%v/v1/auth/token", config.INFISICAL_URL)) if err != nil { - return GetNewAccessTokenWithRefreshTokenResponse{}, err + return GetNewAccessTokenWithRefreshTokenResponse{}, NewGenericRequestError(operationCallGetNewAccessTokenWithRefreshToken, err) } if response.IsError() { - return GetNewAccessTokenWithRefreshTokenResponse{}, fmt.Errorf("CallGetNewAccessTokenWithRefreshToken: Unsuccessful response: [response=%v]", response) + return GetNewAccessTokenWithRefreshTokenResponse{}, NewAPIErrorWithResponse(operationCallGetNewAccessTokenWithRefreshToken, response, nil) } return newAccessToken, nil @@ -280,11 +309,11 @@ func CallGetFoldersV1(httpClient *resty.Client, request GetFoldersV1Request) (Ge response, err := httpRequest.Get(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) if err != nil { - return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unable to complete api request [err=%v]", err) + return GetFoldersV1Response{}, NewGenericRequestError(operationCallGetFoldersV1, err) } if response.IsError() { - return GetFoldersV1Response{}, fmt.Errorf("CallGetFoldersV1: Unsuccessful [response=%s]", response) + return GetFoldersV1Response{}, NewAPIErrorWithResponse(operationCallGetFoldersV1, response, nil) } return foldersResponse, nil @@ -300,11 +329,11 @@ func CallCreateFolderV1(httpClient *resty.Client, request CreateFolderV1Request) response, err := httpRequest.Post(fmt.Sprintf("%v/v1/folders", config.INFISICAL_URL)) if err != nil { - return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unable to complete api request [err=%s]", err) + return CreateFolderV1Response{}, NewGenericRequestError(operationCallCreateFolderV1, err) } if response.IsError() { - return CreateFolderV1Response{}, fmt.Errorf("CallCreateFolderV1: Unsuccessful [response=%s]", response.String()) + return CreateFolderV1Response{}, NewAPIErrorWithResponse(operationCallCreateFolderV1, response, nil) } return folderResponse, nil @@ -321,11 +350,11 @@ func CallDeleteFolderV1(httpClient *resty.Client, request DeleteFolderV1Request) response, err := httpRequest.Delete(fmt.Sprintf("%v/v1/folders/%v", config.INFISICAL_URL, request.FolderName)) if err != nil { - return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unable to complete api request [err=%s]", err) + return DeleteFolderV1Response{}, NewGenericRequestError(operationCallDeleteFolderV1, err) } if response.IsError() { - return DeleteFolderV1Response{}, fmt.Errorf("CallDeleteFolderV1: Unsuccessful [response=%s]", response.String()) + return DeleteFolderV1Response{}, NewAPIErrorWithResponse(operationCallDeleteFolderV1, response, nil) } return folderResponse, nil @@ -342,11 +371,12 @@ func CallDeleteSecretsRawV3(httpClient *resty.Client, request DeleteSecretV3Requ Delete(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallDeleteSecretsV3: Unable to complete api request [err=%s]", err) + return NewGenericRequestError(operationCallDeleteSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallDeleteSecretsV3: Unsuccessful response. Please make sure your secret path, workspace and environment name are all correct [response=%s]", response) + additionalContext := "Please make sure your secret path, workspace and environment name are all correct." + return NewAPIErrorWithResponse(operationCallDeleteSecretsV3, response, &additionalContext) } return nil @@ -362,11 +392,11 @@ func CallCreateServiceToken(httpClient *resty.Client, request CreateServiceToken Post(fmt.Sprintf("%v/v2/service-token/", config.INFISICAL_URL)) if err != nil { - return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unable to complete api request [err=%s]", err) + return CreateServiceTokenResponse{}, NewGenericRequestError(operationCallCreateServiceToken, err) } if response.IsError() { - return CreateServiceTokenResponse{}, fmt.Errorf("CallCreateServiceToken: Unsuccessful response [%v %v] [status-code=%v]", response.Request.Method, response.Request.URL, response.StatusCode()) + return CreateServiceTokenResponse{}, NewAPIErrorWithResponse(operationCallCreateServiceToken, response, nil) } return createServiceTokenResponse, nil @@ -382,11 +412,11 @@ func CallUniversalAuthLogin(httpClient *resty.Client, request UniversalAuthLogin Post(fmt.Sprintf("%v/v1/auth/universal-auth/login/", config.INFISICAL_URL)) if err != nil { - return UniversalAuthLoginResponse{}, fmt.Errorf("CallUniversalAuthLogin: Unable to complete api request [err=%s]", err) + return UniversalAuthLoginResponse{}, NewGenericRequestError(operationCallUniversalAuthLogin, err) } if response.IsError() { - return UniversalAuthLoginResponse{}, fmt.Errorf("CallUniversalAuthLogin: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return UniversalAuthLoginResponse{}, NewAPIErrorWithResponse(operationCallUniversalAuthLogin, response, nil) } return universalAuthLoginResponse, nil @@ -402,11 +432,11 @@ func CallMachineIdentityRefreshAccessToken(httpClient *resty.Client, request Uni Post(fmt.Sprintf("%v/v1/auth/token/renew", config.INFISICAL_URL)) if err != nil { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unable to complete api request [err=%s]", err) + return UniversalAuthRefreshResponse{}, NewGenericRequestError(operationCallMachineIdentityRefreshAccessToken, err) } if response.IsError() { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return UniversalAuthRefreshResponse{}, NewAPIErrorWithResponse(operationCallMachineIdentityRefreshAccessToken, response, nil) } return universalAuthRefreshResponse, nil @@ -441,19 +471,19 @@ func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Reques response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) if err != nil { - return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unable to complete api request [err=%w]", err) + return GetRawSecretsV3Response{}, NewGenericRequestError(operationCallGetRawSecretsV3, err) } if response.IsError() && (strings.Contains(response.String(), "bot_not_found_error") || strings.Contains(strings.ToLower(response.String()), "failed to find bot key") || strings.Contains(strings.ToLower(response.String()), "bot is not active")) { - return GetRawSecretsV3Response{}, fmt.Errorf(`Project with id %s is incompatible with your current CLI version. Upgrade your project by visiting the project settings page. If you're self-hosting and project upgrade option isn't yet available, contact your administrator to upgrade your Infisical instance to the latest release. - `, request.WorkspaceId) + additionalContext := fmt.Sprintf(`Project with id %s is incompatible with your current CLI version. Upgrade your project by visiting the project settings page. If you're self-hosting and project upgrade option isn't yet available, contact your administrator to upgrade your Infisical instance to the latest release.`, request.WorkspaceId) + return GetRawSecretsV3Response{}, NewAPIErrorWithResponse(operationCallGetRawSecretsV3, response, &additionalContext) } if response.IsError() { - return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return GetRawSecretsV3Response{}, NewAPIErrorWithResponse(operationCallGetRawSecretsV3, response, nil) } getRawSecretsV3Response.ETag = response.Header().Get(("etag")) @@ -477,11 +507,11 @@ func CallFetchSingleSecretByName(httpClient *resty.Client, request GetRawSecretV Get(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return GetRawSecretV3ByNameResponse{}, fmt.Errorf("CallFetchSingleSecretByName: Unable to complete api request [err=%w]", err) + return GetRawSecretV3ByNameResponse{}, NewGenericRequestError(operationCallFetchSingleSecretByName, err) } if response.IsError() { - return GetRawSecretV3ByNameResponse{}, fmt.Errorf("CallFetchSingleSecretByName: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return GetRawSecretV3ByNameResponse{}, NewAPIErrorWithResponse(operationCallFetchSingleSecretByName, response, nil) } getRawSecretV3ByNameResponse.ETag = response.Header().Get(("etag")) @@ -517,11 +547,11 @@ func CallCreateRawSecretsV3(httpClient *resty.Client, request CreateRawSecretV3R Post(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallCreateRawSecretsV3: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallCreateRawSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallCreateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallCreateRawSecretsV3, response, nil) } return nil @@ -535,11 +565,11 @@ func CallUpdateRawSecretsV3(httpClient *resty.Client, request UpdateRawSecretByN Patch(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) if err != nil { - return fmt.Errorf("CallUpdateRawSecretsV3: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallUpdateRawSecretsV3, err) } if response.IsError() { - return fmt.Errorf("CallUpdateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallUpdateRawSecretsV3, response, nil) } return nil @@ -554,11 +584,11 @@ func CallRegisterGatewayIdentityV1(httpClient *resty.Client) (*GetRelayCredentia Post(fmt.Sprintf("%v/v1/gateways/register-identity", config.INFISICAL_URL)) if err != nil { - return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallRegisterGatewayIdentityV1, err) } if response.IsError() { - return nil, fmt.Errorf("CallRegisterGatewayIdentityV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallRegisterGatewayIdentityV1, response, nil) } return &resBody, nil @@ -574,11 +604,11 @@ func CallExchangeRelayCertV1(httpClient *resty.Client, request ExchangeRelayCert Post(fmt.Sprintf("%v/v1/gateways/exchange-cert", config.INFISICAL_URL)) if err != nil { - return nil, fmt.Errorf("CallExchangeRelayCertV1: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallExchangeRelayCertV1, err) } if response.IsError() { - return nil, fmt.Errorf("CallExchangeRelayCertV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallExchangeRelayCertV1, response, nil) } return &resBody, nil @@ -591,11 +621,11 @@ func CallGatewayHeartBeatV1(httpClient *resty.Client) error { Post(fmt.Sprintf("%v/v1/gateways/heartbeat", config.INFISICAL_URL)) if err != nil { - return fmt.Errorf("CallGatewayHeartBeatV1: Unable to complete api request [err=%w]", err) + return NewGenericRequestError(operationCallGatewayHeartBeatV1, err) } if response.IsError() { - return fmt.Errorf("CallGatewayHeartBeatV1: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return NewAPIErrorWithResponse(operationCallGatewayHeartBeatV1, response, nil) } return nil @@ -611,11 +641,11 @@ func CallBootstrapInstance(httpClient *resty.Client, request BootstrapInstanceRe Post(fmt.Sprintf("%v/v1/admin/bootstrap", request.Domain)) if err != nil { - return nil, fmt.Errorf("CallBootstrapInstance: Unable to complete api request [err=%w]", err) + return nil, NewGenericRequestError(operationCallBootstrapInstance, err) } if response.IsError() { - return nil, fmt.Errorf("CallBootstrapInstance: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return nil, NewAPIErrorWithResponse(operationCallBootstrapInstance, response, nil) } return resBody, nil diff --git a/cli/packages/api/errors.go b/cli/packages/api/errors.go new file mode 100644 index 000000000..4729d1264 --- /dev/null +++ b/cli/packages/api/errors.go @@ -0,0 +1,80 @@ +package api + +import ( + "fmt" + + "github.com/go-resty/resty/v2" + "github.com/infisical/go-sdk/packages/util" +) + +type GenericRequestError struct { + err error + operation string +} + +func (e *GenericRequestError) Error() string { + return fmt.Sprintf("%s: Unable to complete api request [err=%v]", e.operation, e.err) +} + +func NewGenericRequestError(operation string, err error) *GenericRequestError { + return &GenericRequestError{err: err, operation: operation} +} + +// APIError represents an error response from the API +type APIError struct { + AdditionalContext string `json:"additionalContext,omitempty"` + Operation string `json:"operation"` + Method string `json:"method"` + URL string `json:"url"` + StatusCode int `json:"statusCode"` + ErrorMessage string `json:"message,omitempty"` + ReqId string `json:"reqId,omitempty"` +} + +func (e *APIError) Error() string { + msg := fmt.Sprintf( + "%s Unsuccessful response [%v %v] [status-code=%v] [request-id=%v]", + e.Operation, + e.Method, + e.URL, + e.StatusCode, + e.ReqId, + ) + + if e.ErrorMessage != "" { + msg = fmt.Sprintf("%s [message=\"%s\"]", msg, e.ErrorMessage) + } + + if e.AdditionalContext != "" { + msg = fmt.Sprintf("%s [additional-context=\"%s\"]", msg, e.AdditionalContext) + } + + return msg +} + +func NewAPIErrorWithResponse(operation string, res *resty.Response, additionalContext *string) error { + errorMessage := util.TryParseErrorBody(res) + reqId := util.TryExtractReqId(res) + + if res == nil { + return NewGenericRequestError(operation, fmt.Errorf("response is nil")) + } + + apiError := &APIError{ + Operation: operation, + Method: res.Request.Method, + URL: res.Request.URL, + StatusCode: res.StatusCode(), + ReqId: reqId, + } + + if additionalContext != nil && *additionalContext != "" { + apiError.AdditionalContext = *additionalContext + } + + if errorMessage != "" { + apiError.ErrorMessage = errorMessage + } + + return apiError +} diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index 60f356185..8761b84ef 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -63,7 +63,7 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -72,7 +72,6 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -80,8 +79,9 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -180,7 +180,7 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -189,7 +189,6 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -197,7 +196,7 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -311,7 +310,7 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -320,7 +319,6 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -328,8 +326,9 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -418,7 +417,7 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -427,7 +426,6 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -435,8 +433,9 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -524,7 +523,7 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -533,7 +532,6 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { infisicalToken = token.Token } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -541,7 +539,7 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go index 538f1e2dc..b59652191 100644 --- a/cli/packages/cmd/folder.go +++ b/cli/packages/cmd/folder.go @@ -112,7 +112,7 @@ var createCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get workspace file") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId @@ -180,7 +180,7 @@ var deleteCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get workspace file") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 51565b6fd..90710154e 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -7,16 +7,76 @@ import ( "os/exec" "os/signal" "runtime" + "sync/atomic" "syscall" "time" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/gateway" "github.com/Infisical/infisical-merge/packages/util" + infisicalSdk "github.com/infisical/go-sdk" + "github.com/pkg/errors" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +func getInfisicalSdkInstance(cmd *cobra.Command) (infisicalSdk.InfisicalClientInterface, context.CancelFunc, error) { + + ctx, cancel := context.WithCancel(cmd.Context()) + infisicalClient := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + }) + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + cancel() + return nil, nil, err + } + + // if the --token param is set, we use it directly for authentication + if token != nil { + infisicalClient.Auth().SetAccessToken(token.Token) + return infisicalClient, cancel, nil + } + + // if the --token param is not set, we use the auth-method flag to determine the authentication method, and perform the appropriate login flow based on that + authMethod, err := cmd.Flags().GetString("auth-method") + if err != nil { + cancel() + return nil, nil, err + } + + authMethodValid, strategy := util.IsAuthMethodValid(authMethod, false) + if !authMethodValid { + util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid login method: %s", authMethod)) + } + + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, + util.AuthStrategy.JWT_AUTH: sdkAuthenticator.HandleJwtAuthLogin, + } + + _, err = authStrategies[strategy]() + + if err != nil { + cancel() + return nil, nil, err + } + + return infisicalClient, cancel, nil +} + var gatewayCmd = &cobra.Command{ Use: "gateway", Short: "Run the Infisical gateway or manage its systemd service", @@ -26,13 +86,18 @@ var gatewayCmd = &cobra.Command{ DisableFlagsInUseLine: true, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - token, err := util.GetInfisicalToken(cmd) - if err != nil { - util.HandleError(err, "Unable to parse token flag") - } - if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + infisicalClient, cancelSdk, err := getInfisicalSdkInstance(cmd) + if err != nil { + util.HandleError(err, "unable to get infisical client") + } + defer cancelSdk() + + var accessToken atomic.Value + accessToken.Store(infisicalClient.Auth().GetAccessToken()) + + if accessToken.Load().(string) == "" { + util.HandleError(errors.New("no access token found")) } Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION)) @@ -41,13 +106,14 @@ var gatewayCmd = &cobra.Command{ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sigStopCh := make(chan bool, 1) - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() + ctx, cancelCmd := context.WithCancel(cmd.Context()) + defer cancelCmd() go func() { <-sigCh close(sigStopCh) - cancel() + cancelCmd() + cancelSdk() // If we get a second signal, force exit <-sigCh @@ -55,6 +121,34 @@ var gatewayCmd = &cobra.Command{ os.Exit(1) }() + var gatewayInstance *gateway.Gateway + + // Token refresh goroutine - runs every 10 seconds + go func() { + tokenRefreshTicker := time.NewTicker(10 * time.Second) + defer tokenRefreshTicker.Stop() + + for { + select { + case <-tokenRefreshTicker.C: + if ctx.Err() != nil { + return + } + + newToken := infisicalClient.Auth().GetAccessToken() + if newToken != "" && newToken != accessToken.Load().(string) { + accessToken.Store(newToken) + if gatewayInstance != nil { + gatewayInstance.UpdateIdentityAccessToken(newToken) + } + } + + case <-ctx.Done(): + return + } + } + }() + // Main gateway retry loop with proper context handling retryTicker := time.NewTicker(5 * time.Second) defer retryTicker.Stop() @@ -64,7 +158,7 @@ var gatewayCmd = &cobra.Command{ log.Info().Msg("Shutting down gateway") return } - gatewayInstance, err := gateway.NewGateway(token.Token) + gatewayInstance, err := gateway.NewGateway(accessToken.Load().(string)) if err != nil { util.HandleError(err) } @@ -126,7 +220,7 @@ var gatewayInstallCmd = &cobra.Command{ } if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + util.HandleError(errors.New("Token not found")) } domain, err := cmd.Flags().GetString("domain") @@ -183,7 +277,7 @@ var gatewayRelayCmd = &cobra.Command{ } if relayConfigFilePath == "" { - util.HandleError(fmt.Errorf("Missing config file")) + util.HandleError(errors.New("Missing config file")) } gatewayRelay, err := gateway.NewGatewayRelay(relayConfigFilePath) @@ -198,7 +292,19 @@ var gatewayRelayCmd = &cobra.Command{ } func init() { - gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") + gatewayCmd.Flags().String("token", "", "connect with Infisical using machine identity access token. if not provided, you must set the auth-method flag") + + gatewayCmd.Flags().String("auth-method", "", "login method [universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]. if not provided, you must set the token flag") + + gatewayCmd.Flags().String("client-id", "", "client id for universal auth") + gatewayCmd.Flags().String("client-secret", "", "client secret for universal auth") + + gatewayCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") + gatewayCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") + gatewayCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") + + gatewayCmd.Flags().String("jwt", "", "JWT for jwt-based auth methods [oidc-auth, jwt-auth]") + gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance") diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index e10a11c06..2ef555a82 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -46,7 +46,7 @@ var initCmd = &cobra.Command{ } if userCreds.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + userCreds = util.EstablishUserLoginSession() } httpClient, err := util.GetRestyClientWithCustomHeaders() diff --git a/cli/packages/cmd/kmip.go b/cli/packages/cmd/kmip.go index b0c397895..91335d122 100644 --- a/cli/packages/cmd/kmip.go +++ b/cli/packages/cmd/kmip.go @@ -49,13 +49,13 @@ func startKmipServer(cmd *cobra.Command, args []string) { var identityClientSecret string if strategy == util.AuthStrategy.UNIVERSAL_AUTH { - identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) + identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client ID") } - identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) + identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client secret") } diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index b1c868d8c..ef549aabe 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "os" + "runtime" "slices" "strings" "time" @@ -20,6 +21,8 @@ import ( "net/url" "regexp" + browser "github.com/pkg/browser" + "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/crypto" @@ -46,97 +49,6 @@ type params struct { keyLength uint32 } -func handleUniversalAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - clientId, err := util.GetCmdFlagOrEnv(cmd, "client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) - - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - clientSecret, err := util.GetCmdFlagOrEnv(cmd, "client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) -} - -func handleKubernetesAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountTokenPath, err := util.GetCmdFlagOrEnv(cmd, "service-account-token-path", util.INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) -} - -func handleAzureAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AzureAuthLogin(identityId, "") -} - -func handleGcpIdTokenAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) -} - -func handleGcpIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountKeyFilePath, err := util.GetCmdFlagOrEnv(cmd, "service-account-key-file-path", util.INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) -} - -func handleAwsIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AwsIamAuthLogin(identityId) -} - -func handleOidcAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - jwt, err := util.GetCmdFlagOrEnv(cmd, "oidc-jwt", util.INFISICAL_OIDC_AUTH_JWT_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().OidcAuthLogin(identityId, jwt) -} - func formatAuthMethod(authMethod string) string { return strings.ReplaceAll(authMethod, "-", " ") } @@ -151,8 +63,22 @@ var loginCmd = &cobra.Command{ Use: "login", Short: "Login into your Infisical account", DisableFlagsInUseLine: true, - Run: func(cmd *cobra.Command, args []string) { + PreRunE: func(cmd *cobra.Command, args []string) error { + // daniel: oidc-jwt is deprecated in favor of `jwt`. we backfill the `jwt` flag with the value of `oidc-jwt` if it's set. + if cmd.Flags().Changed("oidc-jwt") && !cmd.Flags().Changed("jwt") { + oidcJWT, err := cmd.Flags().GetString("oidc-jwt") + if err != nil { + return err + } + err = cmd.Flags().Set("jwt", oidcJWT) + if err != nil { + return err + } + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { presetDomain := config.INFISICAL_URL clearSelfHostedDomains, err := cmd.Flags().GetBool("clear-domains") @@ -307,17 +233,19 @@ var loginCmd = &cobra.Command{ Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) } else { - authStrategies := map[util.AuthStrategyType]func(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error){ - util.AuthStrategy.UNIVERSAL_AUTH: handleUniversalAuthLogin, - util.AuthStrategy.KUBERNETES_AUTH: handleKubernetesAuthLogin, - util.AuthStrategy.AZURE_AUTH: handleAzureAuthLogin, - util.AuthStrategy.GCP_ID_TOKEN_AUTH: handleGcpIdTokenAuthLogin, - util.AuthStrategy.GCP_IAM_AUTH: handleGcpIamAuthLogin, - util.AuthStrategy.AWS_IAM_AUTH: handleAwsIamAuthLogin, - util.AuthStrategy.OIDC_AUTH: handleOidcAuthLogin, + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, } - credential, err := authStrategies[strategy](cmd, infisicalClient) + credential, err := authStrategies[strategy]() if err != nil { euErrorMessage := "" @@ -515,14 +443,18 @@ func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().Bool("clear-domains", false, "clear all self-hosting domains from the config file") loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") - loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") + loginCmd.Flags().String("method", "user", "login method [user, universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]") loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().String("client-secret", "", "client secret for universal auth") loginCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") - loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication") + loginCmd.Flags().String("jwt", "", "jwt for jwt-based auth methods [oidc-auth, jwt-auth]") + loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication. Deprecated, use --jwt instead") + + loginCmd.Flags().MarkDeprecated("oidc-jwt", "use --jwt instead") + } func DomainOverridePrompt() (bool, error) { @@ -981,7 +913,17 @@ func browserCliLogin() (models.UserCredentials, error) { callbackPort := listener.Addr().(*net.TCPAddr).Port url := fmt.Sprintf("%s?callback_port=%d", config.INFISICAL_LOGIN_URL, callbackPort) - fmt.Printf("\n\nTo complete your login, open this address in your browser: %v \n", url) + defaultPrintStatement := fmt.Sprintf("\n\nTo complete your login, open this address in your browser: %v \n", url) + + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + if err := browser.OpenURL(url); err != nil { + fmt.Print(defaultPrintStatement) + } else { + fmt.Printf("\n\nPlease proceed to your browser to complete the login process.\nIf the browser doesn't open automatically, please open this address in your browser: %v \n", url) + } + } else { + fmt.Print(defaultPrintStatement) + } //flow channels success := make(chan models.UserCredentials) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index fdee3e7c0..930a27a56 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -158,10 +158,6 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - if token == nil { - util.RequireLocalWorkspaceFile() - } - environmentName, _ := cmd.Flags().GetString("env") if !cmd.Flags().Changed("env") { environmentFromWorkspace := util.GetEnvFromWorkspaceFile() @@ -175,6 +171,13 @@ var secretsSetCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } + if token == nil && projectId == "" { + _, err := util.GetWorkSpaceFromFile() + if err != nil { + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -225,7 +228,7 @@ var secretsSetCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "unable to get your local config details [err=%v]") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId @@ -237,7 +240,7 @@ var secretsSetCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } secretOperations, err = util.SetRawSecrets(processedArgs, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ @@ -308,7 +311,7 @@ var secretsDeleteCmd = &cobra.Command{ if projectId == "" { workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { - util.HandleError(err, "Unable to get local project details") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -317,7 +320,6 @@ var secretsDeleteCmd = &cobra.Command{ httpClient.SetAuthToken(token.Token) } else { util.RequireLogin() - util.RequireLocalWorkspaceFile() loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if err != nil { @@ -325,7 +327,7 @@ var secretsDeleteCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index 7f74d8ee6..4315989bd 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -184,7 +184,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -417,7 +417,7 @@ func signKey(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -612,7 +612,7 @@ func sshConnect(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse flag") } - + var infisicalToken string if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { @@ -626,7 +626,7 @@ func sshConnect(cmd *cobra.Command, args []string) { } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -640,7 +640,7 @@ func sshConnect(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse flag") } - + hostname, _ := cmd.Flags().GetString("hostname") loginUser, _ := cmd.Flags().GetString("login-user") @@ -858,7 +858,7 @@ func sshConnect(cmd *cobra.Command, args []string) { err = sshCmd.Run() if err != nil { util.HandleError(err, "SSH connection failed") - } + } } func sshAddHost(cmd *cobra.Command, args []string) { @@ -879,7 +879,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to authenticate") } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login]") + loggedInUserDetails = util.EstablishUserLoginSession() } infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } @@ -904,7 +904,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if err != nil { util.HandleError(err, "Unable to parse --alias flag") } - + // if alias == "" { // util.PrintErrorMessageAndExit("You must provide --alias") // } @@ -937,7 +937,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if configureSshd && (!writeUserCaToFile || !writeHostCertToFile) { util.PrintErrorMessageAndExit("--configure-sshd requires both --write-user-ca-to-file and --write-host-cert-to-file to also be set") } - + // Pre-check for file overwrites before proceeding if writeUserCaToFile { if strings.HasPrefix(userCaOutFilePath, "~") { diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index 386a7eda5..a2e445239 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -47,7 +47,7 @@ var tokensCreateCmd = &cobra.Command{ } if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } tokenOnly, err := cmd.Flags().GetBool("token-only") diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 2879e4ccb..6c7d54d46 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -111,8 +111,9 @@ var userGetTokenCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails(true) if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = util.EstablishUserLoginSession() } + if err != nil { util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") } diff --git a/cli/packages/gateway/connection.go b/cli/packages/gateway/connection.go index 58a0503ff..9274086a1 100644 --- a/cli/packages/gateway/connection.go +++ b/cli/packages/gateway/connection.go @@ -4,11 +4,18 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" + "fmt" "io" "net" + "net/http" + "os" "strings" "sync" + "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog/log" @@ -89,6 +96,34 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { CopyDataFromQuicToTcp(stream, destTarget) log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String()) return + + case "FORWARD-HTTP": + argParts := bytes.Split(args, []byte(" ")) + if len(argParts) == 0 { + log.Error().Msg("FORWARD-HTTP requires target URL") + return + } + + targetURL := string(argParts[0]) + + // Parse optional parameters + var caCertB64, verifyParam string + for _, part := range argParts[1:] { + partStr := string(part) + if strings.HasPrefix(partStr, "ca=") { + caCertB64 = strings.TrimPrefix(partStr, "ca=") + } else if strings.HasPrefix(partStr, "verify=") { + verifyParam = strings.TrimPrefix(partStr, "verify=") + } + } + + log.Info().Msgf("Starting HTTP proxy to: %s", targetURL) + + if err := handleHTTPProxy(stream, reader, targetURL, caCertB64, verifyParam); err != nil { + log.Error().Msgf("HTTP proxy error: %v", err) + } + return + case "PING": if _, err := stream.Write([]byte("PONG\n")); err != nil { log.Error().Msgf("Error writing PONG response: %v", err) @@ -100,6 +135,121 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { } } } +func handleHTTPProxy(stream quic.Stream, reader *bufio.Reader, targetURL string, caCertB64 string, verifyParam string) error { + transport := &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + } + + if strings.HasPrefix(targetURL, "https://") { + tlsConfig := &tls.Config{} + + if caCertB64 != "" { + caCert, err := base64.StdEncoding.DecodeString(caCertB64) + if err == nil { + caCertPool := x509.NewCertPool() + if caCertPool.AppendCertsFromPEM(caCert) { + tlsConfig.RootCAs = caCertPool + log.Info().Msg("Using provided CA certificate from gateway client") + } else { + log.Error().Msg("Failed to parse provided CA certificate") + } + } else { + log.Error().Msgf("Failed to decode CA certificate: %v", err) + } + } + + // set certificate verification based on what the gateway client sent + if verifyParam != "" { + tlsConfig.InsecureSkipVerify = verifyParam == "false" + log.Info().Msgf("TLS verification set to: %s", verifyParam) + } + + transport.TLSClientConfig = tlsConfig + } + + // read and parse the http request from the stream + req, err := http.ReadRequest(reader) + if err != nil { + return fmt.Errorf("failed to read HTTP request: %v", err) + } + + actionHeader := req.Header.Get("x-infisical-action") + if actionHeader != "" { + + if actionHeader == "inject-k8s-sa-auth-token" { + token, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa auth token"))) + return fmt.Errorf("failed to read k8s sa auth token: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token))) + log.Info().Msgf("Injected gateway k8s SA auth token in request to %s", targetURL) + } + + req.Header.Del("x-infisical-action") + } + + var targetFullURL string + if strings.HasPrefix(targetURL, "http://") || strings.HasPrefix(targetURL, "https://") { + baseURL := strings.TrimSuffix(targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } else { + baseURL := strings.TrimSuffix("http://"+targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } + + // create the request to the target + proxyReq, err := http.NewRequest(req.Method, targetFullURL, req.Body) + proxyReq.Header = req.Header.Clone() + if err != nil { + return fmt.Errorf("failed to create proxy request: %v", err) + } + + log.Info().Msgf("Proxying %s %s to %s", req.Method, req.URL.Path, targetFullURL) + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + // make the request to the target + resp, err := client.Do(proxyReq) + if err != nil { + stream.Write([]byte(buildHttpInternalServerError(fmt.Sprintf("failed to reach target due to networking error: %s", err.Error())))) + return fmt.Errorf("failed to reach target due to networking error: %v", err) + } + defer resp.Body.Close() + + // Write the entire response (status line, headers, body) to the stream + // http.Response.Write handles this for "Connection: close" correctly. + // For other connection tokens, manual removal might be needed if they cause issues with QUIC. + // For a simple proxy, this is generally sufficient. + resp.Header.Del("Connection") // Good practice for proxies + + log.Info().Msgf("Writing response to stream: %s", resp.Status) + if err := resp.Write(stream); err != nil { + // If writing the response fails, the connection to the client might be broken. + // Logging the error is important. The original error will be returned. + log.Error().Err(err).Msg("Failed to write response to stream") + return fmt.Errorf("failed to write response to stream: %w", err) + } + + return nil +} + +func buildHttpInternalServerError(message string) string { + return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message) +} type CloseWrite interface { CloseWrite() error diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index d0a25ca9c..eb0c72d5d 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -54,6 +54,10 @@ func NewGateway(identityToken string) (Gateway, error) { }, nil } +func (g *Gateway) UpdateIdentityAccessToken(accessToken string) { + g.httpClient.SetAuthToken(accessToken) +} + func (g *Gateway) ConnectWithRelay() error { relayDetails, err := api.CallRegisterGatewayIdentityV1(g.httpClient) if err != nil { diff --git a/cli/packages/util/auth.go b/cli/packages/util/auth.go index cdcd7b50a..eaf7cecc1 100644 --- a/cli/packages/util/auth.go +++ b/cli/packages/util/auth.go @@ -1,5 +1,15 @@ package util +import ( + "fmt" + "os" + "os/exec" + + infisicalSdk "github.com/infisical/go-sdk" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + type AuthStrategyType string var AuthStrategy = struct { @@ -10,6 +20,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH AuthStrategyType AWS_IAM_AUTH AuthStrategyType OIDC_AUTH AuthStrategyType + JWT_AUTH AuthStrategyType }{ UNIVERSAL_AUTH: "universal-auth", KUBERNETES_AUTH: "kubernetes", @@ -18,6 +29,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH: "gcp-iam", AWS_IAM_AUTH: "aws-iam", OIDC_AUTH: "oidc-auth", + JWT_AUTH: "jwt-auth", } var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ @@ -28,6 +40,7 @@ var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ AuthStrategy.GCP_IAM_AUTH, AuthStrategy.AWS_IAM_AUTH, AuthStrategy.OIDC_AUTH, + AuthStrategy.JWT_AUTH, } func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, strategy AuthStrategyType) { @@ -43,3 +56,153 @@ func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, str } return false, "" } + +// EstablishUserLoginSession handles the login flow to either create a new session or restore an expired one. +// It returns fresh user details if login is successful. +func EstablishUserLoginSession() LoggedInUserDetails { + log.Info().Msg("No valid login session found, triggering login flow") + + exePath, err := os.Executable() + if err != nil { + PrintErrorMessageAndExit(fmt.Sprintf("Failed to determine executable path: %v", err)) + } + + // Spawn infisical login command + loginCmd := exec.Command(exePath, "login", "--silent") + loginCmd.Stdin = os.Stdin + loginCmd.Stdout = os.Stdout + loginCmd.Stderr = os.Stderr + + err = loginCmd.Run() + if err != nil { + PrintErrorMessageAndExit(fmt.Sprintf("Failed to automatically trigger login flow. Please run [infisical login] manually to login.")) + } + + loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) + if err != nil { + PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired. Please run [infisical login]") + } + + return loggedInUserDetails +} + +type SdkAuthenticator struct { + infisicalClient infisicalSdk.InfisicalClientInterface + cmd *cobra.Command +} + +func NewSdkAuthenticator(infisicalClient infisicalSdk.InfisicalClientInterface, cmd *cobra.Command) *SdkAuthenticator { + return &SdkAuthenticator{ + infisicalClient: infisicalClient, + cmd: cmd, + } +} +func (a *SdkAuthenticator) HandleUniversalAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + clientId, err := GetCmdFlagOrEnv(a.cmd, "client-id", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) + + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + clientSecret, err := GetCmdFlagOrEnv(a.cmd, "client-secret", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) +} + +func (a *SdkAuthenticator) HandleJwtAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().JwtAuthLogin(identityId, jwt) +} + +func (a *SdkAuthenticator) HandleKubernetesAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountTokenPath, err := GetCmdFlagOrEnv(a.cmd, "service-account-token-path", []string{INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) +} + +func (a *SdkAuthenticator) HandleAzureAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AzureAuthLogin(identityId, "") +} + +func (a *SdkAuthenticator) HandleGcpIdTokenAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleGcpIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountKeyFilePath, err := GetCmdFlagOrEnv(a.cmd, "service-account-key-file-path", []string{INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) +} + +func (a *SdkAuthenticator) HandleAwsIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AwsIamAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleOidcAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME, INFISICAL_OIDC_AUTH_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().OidcAuthLogin(identityId, jwt) +} diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 8b4c586e6..68fda6d50 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -24,7 +24,10 @@ const ( INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME = "INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH" // OIDC Auth - INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" + INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" // deprecated in favor of INFISICAL_JWT + + // JWT AUTH + INFISICAL_JWT_NAME = "INFISICAL_JWT" // Generic env variable used for auth methods that require a machine identity ID INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID" diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 6bba05842..fb4f2a322 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -15,7 +15,6 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder var folderErr error if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { RequireLogin() - RequireLocalWorkspaceFile() log.Debug().Msg("GetAllFolders: Trying to fetch folders using logged in details") @@ -25,19 +24,18 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } - workspaceFile, err := GetWorkSpaceFromFile() - if err != nil { - return nil, err + if params.WorkspaceId == "" { + workspaceFile, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + params.WorkspaceId = workspaceFile.WorkspaceId } - if params.WorkspaceId != "" { - workspaceFile.WorkspaceId = params.WorkspaceId - } - - folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, workspaceFile.WorkspaceId, params.Environment, params.FoldersPath) + folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.FoldersPath) folderErr = err foldersToReturn = folders } else if params.InfisicalToken != "" { @@ -186,7 +184,6 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er // If no token is provided, we will try to get the token from the current logged in user if params.InfisicalToken == "" { RequireLogin() - RequireLocalWorkspaceFile() loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) if err != nil { @@ -194,7 +191,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken @@ -235,7 +232,6 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, // If no token is provided, we will try to get the token from the current logged in user if params.InfisicalToken == "" { RequireLogin() - RequireLocalWorkspaceFile() loggedInUserDetails, err := GetCurrentLoggedInUserDetails(true) @@ -244,7 +240,7 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, } if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 346122a64..fc3f994a7 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -174,7 +174,7 @@ func RequireLogin() { configFile, _ := GetConfigFile() if configFile.LoggedInUserEmail == "" { - PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") + EstablishUserLoginSession() } } @@ -292,13 +292,18 @@ func GetEnvVarOrFileContent(envName string, filePath string) (string, error) { return fileContent, nil } -func GetCmdFlagOrEnv(cmd *cobra.Command, flag, envName string) (string, error) { +func GetCmdFlagOrEnv(cmd *cobra.Command, flag string, envNames []string) (string, error) { value, flagsErr := cmd.Flags().GetString(flag) if flagsErr != nil { return "", flagsErr } if value == "" { - value = os.Getenv(envName) + for _, env := range envNames { + value = strings.TrimSpace(os.Getenv(env)) + if value != "" { + break + } + } } if value == "" { return "", fmt.Errorf("please provide %s flag", flag) diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 0693db509..814e7da23 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -251,10 +251,15 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo var errorToReturn error if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { - if projectConfigFilePath == "" { - RequireLocalWorkspaceFile() - } else { - ValidateWorkspaceFile(projectConfigFilePath) + if params.WorkspaceId == "" { + if projectConfigFilePath == "" { + _, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + } else { + ValidateWorkspaceFile(projectConfigFilePath) + } } RequireLogin() @@ -273,32 +278,31 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo } if isConnected && loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + loggedInUserDetails = EstablishUserLoginSession() } - var infisicalDotJson models.WorkspaceConfigFile + if params.WorkspaceId == "" { + var infisicalDotJson models.WorkspaceConfigFile - if projectConfigFilePath == "" { - projectConfig, err := GetWorkSpaceFromFile() - if err != nil { - return nil, err + if projectConfigFilePath == "" { + projectConfig, err := GetWorkSpaceFromFile() + if err != nil { + PrintErrorMessageAndExit("Please either run infisical init to connect to a project or pass in project id with --projectId flag") + } + + infisicalDotJson = projectConfig + } else { + projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) + if err != nil { + return nil, err + } + + infisicalDotJson = projectConfig } - - infisicalDotJson = projectConfig - } else { - projectConfig, err := GetWorkSpaceFromFilePath(projectConfigFilePath) - if err != nil { - return nil, err - } - - infisicalDotJson = projectConfig + params.WorkspaceId = infisicalDotJson.WorkspaceId } - if params.WorkspaceId != "" { - infisicalDotJson.WorkspaceId = params.WorkspaceId - } - - res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, infisicalDotJson.WorkspaceId, + res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true) log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) @@ -307,7 +311,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if err != nil { return nil, err } - WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, res.Secrets) + WriteBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey, res.Secrets) } secretsToReturn = res.Secrets @@ -316,7 +320,7 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo if !isConnected { backupEncryptionKey, _ := GetBackupEncryptionKey() if backupEncryptionKey != nil { - backedUpSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) + backedUpSecrets, err := ReadBackupSecrets(params.WorkspaceId, params.Environment, params.SecretsPath, backupEncryptionKey) if len(backedUpSecrets) > 0 { PrintWarning("Unable to fetch the latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") secretsToReturn = backedUpSecrets diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment index b447d947e..6047b33e0 100644 --- a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -1,4 +1,4 @@ -error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [response={"error":"NotFound","message":"Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found","statusCode":404}] +error: CallGetRawSecretsV3 Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&expandSecretReferences=true&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=404] [request-id=] [message="Environment with slug 'invalid-env' in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2 not found"] If this issue continues, get support at https://infisical.com/slack diff --git a/cli/test/helper.go b/cli/test/helper.go index 21bd261df..74e56237a 100644 --- a/cli/test/helper.go +++ b/cli/test/helper.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/exec" + "regexp" "strings" ) @@ -71,7 +72,11 @@ func SetupCli() { } func FilterRequestID(input string) string { - // Find the JSON part of the error message + requestIDPattern := regexp.MustCompile(`\[request-id=[^\]]+\]`) + reqIDPattern := regexp.MustCompile(`\[reqId=[^\]]+\]`) + input = requestIDPattern.ReplaceAllString(input, "[request-id=]") + input = reqIDPattern.ReplaceAllString(input, "[reqId=]") + start := strings.Index(input, "{") end := strings.LastIndex(input, "}") + 1 diff --git a/docs/api-reference/endpoints/app-connections/github-radar/available.mdx b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx new file mode 100644 index 000000000..6cfc0758c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/github-radar/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/create.mdx b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx new file mode 100644 index 000000000..0cd66a49b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github-radar" +--- + + + GitHub Radar Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx new file mode 100644 index 000000000..64b252538 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx new file mode 100644 index 000000000..1ffc291c9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github-radar/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx new file mode 100644 index 000000000..a5ca5e3f5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github-radar/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/list.mdx b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx new file mode 100644 index 000000000..2bd832941 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github-radar" +--- diff --git a/docs/api-reference/endpoints/app-connections/github-radar/update.mdx b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx new file mode 100644 index 000000000..4ffb88dc5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github-radar/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github-radar/{connectionId}" +--- + + + GitHub Radar Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Radar Connections](/integrations/app-connections/github-radar) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/mysql/available.mdx b/docs/api-reference/endpoints/app-connections/mysql/available.mdx new file mode 100644 index 000000000..820f137fb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/mysql/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/create.mdx b/docs/api-reference/endpoints/app-connections/mysql/create.mdx new file mode 100644 index 000000000..c91826441 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/mysql" +--- + + + Check out the configuration docs for [MySQL Connections](/integrations/app-connections/mysql) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/mysql/delete.mdx b/docs/api-reference/endpoints/app-connections/mysql/delete.mdx new file mode 100644 index 000000000..29a0b6afd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/mysql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx new file mode 100644 index 000000000..b14cc6b10 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/mysql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx new file mode 100644 index 000000000..f45c0d178 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/mysql/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/list.mdx b/docs/api-reference/endpoints/app-connections/mysql/list.mdx new file mode 100644 index 000000000..1c175723f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/mysql" +--- diff --git a/docs/api-reference/endpoints/app-connections/mysql/update.mdx b/docs/api-reference/endpoints/app-connections/mysql/update.mdx new file mode 100644 index 000000000..8a000199f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mysql/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/mysql/{connectionId}" +--- + + + Check out the configuration docs for [MySQL Connections](/integrations/app-connections/mysql) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx new file mode 100644 index 000000000..a0b9b745d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/mysql-credentials" +--- + + + Check out the configuration docs for [MySQL Credentials Rotations](/documentation/platform/secret-rotation/mysql-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx new file mode 100644 index 000000000..40e98bf9d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx new file mode 100644 index 000000000..5914e275f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx new file mode 100644 index 000000000..1e4868e75 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..b8762fee5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/mysql-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx new file mode 100644 index 000000000..9065ecf0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/mysql-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..8ffe010c3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/mysql-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx new file mode 100644 index 000000000..25e393688 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mysql-credentials/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/mysql-credentials/{rotationId}" +--- + + + Check out the configuration docs for [MySQL Credentials Rotations](/documentation/platform/secret-rotation/mysql-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx new file mode 100644 index 000000000..8204a3098 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/get-by-project-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Project ID" +openapi: "GET /api/v2/secret-scanning/configs" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/config/update.mdx b/docs/api-reference/endpoints/secret-scanning/config/update.mdx new file mode 100644 index 000000000..bf068a20f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/config/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/configs" +--- + + + Check out the [Configuration Docs](/documentation/platform/secret-scanning/overview#configuration) for an in-depth guide on custom configurations. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx new file mode 100644 index 000000000..248c8cf53 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx new file mode 100644 index 000000000..eb791e6db --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx new file mode 100644 index 000000000..0103ad447 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx new file mode 100644 index 000000000..c86dcc948 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-scanning/data-sources/github/data-source-name/{dataSourceName}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx new file mode 100644 index 000000000..f2627827f --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-resources.mdx @@ -0,0 +1,4 @@ +--- +title: "List Resources" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx new file mode 100644 index 000000000..839b66355 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list-scans.mdx @@ -0,0 +1,4 @@ +--- +title: "List Scans" +openapi: "GET /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scans" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx new file mode 100644 index 000000000..951f827a8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources/github" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx new file mode 100644 index 000000000..5025126fd --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan-resource.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan Resource" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/resources/{resourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx new file mode 100644 index 000000000..f6b21b485 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/scan.mdx @@ -0,0 +1,4 @@ +--- +title: "Scan" +openapi: "POST /api/v2/secret-scanning/data-sources/github/{dataSourceId}/scan" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx new file mode 100644 index 000000000..2d800d9d5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/github/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/data-sources/github/{dataSourceId}" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx new file mode 100644 index 000000000..0958dc382 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/data-sources" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx new file mode 100644 index 000000000..3affb5270 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/data-sources/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v2/secret-scanning/data-sources/options" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/list.mdx b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx new file mode 100644 index 000000000..6a97a3a20 --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-scanning/findings" +--- diff --git a/docs/api-reference/endpoints/secret-scanning/findings/update.mdx b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx new file mode 100644 index 000000000..1c9614f8b --- /dev/null +++ b/docs/api-reference/endpoints/secret-scanning/findings/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-scanning/findings/{findingId}" +--- \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx index 2cf4edc0e..66c4c706a 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -60,7 +60,7 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png) @@ -94,21 +94,29 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa - If you want to provide specific privileges for the generated dynamic credentials, you can modify the ElastiCache statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify ElastiCache Statements Modal](/images/platform/dynamic-secrets/modify-elasticache-statement.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify ElastiCache Statements Modal](/images/platform/dynamic-secrets/modify-elasticache-statement.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the ElastiCache statement to your needs. This is useful if you want to only give access to a specific resource. + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certificate. + If this step fails, you may have to add the CA certificate. - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) @@ -123,14 +131,14 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) @@ -141,4 +149,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 730e2b287..56a10419c 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -40,7 +40,7 @@ Infisical needs an initial AWS IAM user with the required permissions to create } ``` -To minimize managing user access you can attach a resource in format +To minimize managing user access you can attach a resource in format > arn:aws:iam::\:user/\ @@ -94,28 +94,36 @@ Replace **\** with your AWS account id and **\** w - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas + The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas + The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas + The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas + + + +Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + +Allowed template variables are +- `{{randomUsername}}`: Random username string +- `{{unixTimestamp}}`: Current Unix timestamp ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -130,14 +138,14 @@ Replace **\** with your AWS account id and **\** w - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the lease details and delete the lease ahead of its expiration time. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx index e7ec4f69d..628432bea 100644 --- a/docs/documentation/platform/dynamic-secrets/cassandra.mdx +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -7,7 +7,7 @@ The Infisical Cassandra dynamic secret allows you to generate Cassandra database ## Prerequisite -Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. +Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. @@ -19,7 +19,7 @@ authorizer: CassandraAuthorizer ``` -The above configuration allows user creation and granting permissions. +The above configuration allows user creation and granting permissions. ## Set up Dynamic Secrets with Cassandra @@ -69,31 +69,39 @@ The above configuration allows user creation and granting permissions. Keyspace name where you want to create dynamic secrets. This ensures that the user is limited to that keyspace. - + - A CA may be required if your cassandra requires it for incoming connections. + A CA may be required if your cassandra requires it for incoming connections. ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png) - If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -108,14 +116,14 @@ The above configuration allows user creation and granting permissions. - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the lease details and delete the lease ahead of its expiration time. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx index 0e1bc5104..6c5028bb2 100644 --- a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx +++ b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx @@ -7,13 +7,14 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch ## Prerequisites - - 1. Create a role with at least `manage_security` and `monitor` permissions. 2. Assign the newly created role to your API key or user that you'll use later in the dynamic secret configuration. - For testing purposes, you can also use a highly privileged role like `superuser`, that will have full control over the cluster. This is not recommended in production environments following the principle of least privilege. + For testing purposes, you can also use a highly privileged role like + `superuser`, that will have full control over the cluster. This is not + recommended in production environments following the principle of least + privilege. ## Set up Dynamic Secrets with Elasticsearch @@ -33,95 +34,115 @@ The Infisical Elasticsearch dynamic secret allows you to generate Elasticsearch Name by which you want the secret to be referenced - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) - + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + - - Maximum time-to-live for a generated secret. - + + Maximum time-to-live for a generated secret. + - + Your Elasticsearch host. This is the endpoint that your instance runs on. _(Example: https://your-cluster-ip)_ - + - - The port that your Elasticsearch instance is running on. _(Example: 9200)_ - + - - The roles that the new user that is created when a lease is provisioned will be assigned to. This is a required field. This defaults to `superuser`, which is highly privileged. It is recommended to create a new role with the least privileges required for the lease. - +The port that your Elasticsearch instance is running on. _(Example: 9200)_ + - + + The roles that the new user that is created when a lease is provisioned will + be assigned to. This is a required field. This defaults to `superuser`, which + is highly privileged. It is recommended to create a new role with the least + privileges required for the lease. + + + Select the authentication method you want to use to connect to your Elasticsearch instance. - + - - The username of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. - + + The username of the user that will be used to provision new dynamic secret + leases. Only required if you selected the `Username/Password` authentication + method. + - - The password of the user that will be used to provision new dynamic secret leases. Only required if you selected the `Username/Password` authentication method. - + + The password of the user that will be used to provision new dynamic secret + leases. Only required if you selected the `Username/Password` authentication + method. + - - The ID of the API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. - + + The ID of the API key that will be used to provision new dynamic secret + leases. Only required if you selected the `API Key` authentication method. + - - The API key that will be used to provision new dynamic secret leases. Only required if you selected the `API Key` authentication method. - + + The API key that will be used to provision new dynamic secret leases. Only + required if you selected the `API Key` authentication method. + - - A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. - + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + +![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certificate. - + + If this step fails, you may have to add the CA certificate. + - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) - When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. - + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) - ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) ## Renew Leases + To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) - Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx new file mode 100644 index 000000000..d6d051ff7 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -0,0 +1,243 @@ +--- +title: "Kubernetes" +description: "Learn how to dynamically generate Kubernetes service account tokens." +--- + +The Infisical Kubernetes dynamic secret allows you to generate short-lived service account tokens on demand. + +## Overview + +The Kubernetes dynamic secret feature enables you to generate short-lived service account tokens for your Kubernetes clusters. This is particularly useful for: + +- **Secure Access Management**: Instead of using long-lived service account tokens, you can generate short-lived tokens that automatically expire, reducing the risk of token exposure. +- **Temporary Access**: Generate tokens with specific TTLs (Time To Live) for temporary access to your Kubernetes clusters. +- **Audit Trail**: Each token generation is tracked, providing better visibility into who accessed your cluster and when. +- **Integration with Private Clusters**: Seamlessly work with private Kubernetes clusters using Infisical's Gateway feature. + + + Kubernetes service account tokens cannot be revoked once issued. This is why + it's important to use short TTLs and carefully manage token generation. The + tokens will automatically expire after their TTL period. + + + + Kubernetes service account tokens are JWTs (JSON Web Tokens) with a fixed + expiration time. Once a token is generated, its lifetime cannot be extended. + If you need longer access, you'll need to generate a new token. + + +This feature is ideal for scenarios where you need to: + +- Provide temporary access to developers or CI/CD pipelines +- Rotate service account tokens frequently +- Maintain a secure audit trail of cluster access +- Manage access to multiple Kubernetes clusters + +## Prerequisites + +- A Kubernetes cluster with a service account +- Cluster access token with permissions to create service account tokens +- (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + +## RBAC Configuration + +Before you can start generating dynamic service account tokens, you'll need to configure the appropriate permissions in your Kubernetes cluster. This involves setting up Role-Based Access Control (RBAC) to allow the creation and management of service account tokens. + +The RBAC configuration serves a crucial security purpose: it creates a dedicated service account with minimal permissions that can only create and manage service account tokens. This follows the principle of least privilege, ensuring that the token generation process is secure and controlled. + +The following RBAC configuration creates the necessary permissions for generating service account tokens: + +```yaml rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: tokenrequest +rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: tokenrequest +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: tokenrequest +subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default +``` + +```bash +kubectl apply -f rbac.yaml +``` + +This configuration: + +1. Creates a `ClusterRole` named `tokenrequest` that allows: + - Creating and getting service account tokens + - Getting service account information +2. Creates a `ClusterRoleBinding` that binds the role to a service account named `infisical-token-requester` in the `default` namespace + +You can customize the service account name and namespace according to your needs. + +## Obtaining the Cluster Token + +After setting up the RBAC configuration, you need to obtain a token for the service account that will be used to create dynamic secrets. Here's how to get the token: + +1. Create a service account in your Kubernetes cluster that will be used to create service account tokens: + +```yaml infisical-service-account.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: infisical-token-requester + namespace: default +``` + +```bash +kubectl apply -f infisical-service-account.yaml +``` + +2. Create a long-lived service account token using this configuration file: + +```yaml service-account-token.yaml +apiVersion: v1 +kind: Secret +type: kubernetes.io/service-account-token +metadata: + name: infisical-token-requester-token + annotations: + kubernetes.io/service-account.name: "infisical-token-requester" +``` + +```bash +kubectl apply -f service-account-token.yaml +``` + +3. Link the secret to the service account: + +```bash +kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default +``` + +4. Retrieve the token: + +```bash +kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode +``` + +This token will be used as the "Cluster Token" in the dynamic secret configuration. + +## Obtaining the Cluster URL + +The cluster URL is the address of your Kubernetes API server. The simplest way to find it is to use the `kubectl cluster-info` command: + +```bash +kubectl cluster-info +``` + +This command works for all Kubernetes environments (managed services like GKE, EKS, AKS, or self-hosted clusters) and will show you the Kubernetes control plane address, which is your cluster URL. + + + Make sure the cluster URL is accessible from where you're running Infisical. + If you're using a private cluster, you'll need to configure a [Gateway](/documentation/platform/gateways/overview) to + access it. + + +## Set up Dynamic Secrets with Kubernetes + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](/images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png) + + + + Name by which you want the secret to be referenced + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + Maximum time-to-live for a generated secret + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + Kubernetes API server URL (e.g., https://kubernetes.default.svc) + + + Whether to enable SSL verification for the Kubernetes API server connection. + + + Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. + + + Token with permissions to create service account tokens + + + Name of the service account to generate tokens for + + + Kubernetes namespace where the service account exists + + + Optional list of audiences to include in the generated token + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png) + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand service account tokens. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the service account token will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/kubernetes-lease-value.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the lease details and delete the lease ahead of its expiration time. + + + While you can delete the lease from Infisical, the actual Kubernetes service + account token cannot be revoked. The token will remain valid until its TTL + expires. This is why it's crucial to use appropriate TTL values when + generating tokens. + + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/ldap.mdx b/docs/documentation/platform/dynamic-secrets/ldap.mdx index a1731432c..1a3eca404 100644 --- a/docs/documentation/platform/dynamic-secrets/ldap.mdx +++ b/docs/documentation/platform/dynamic-secrets/ldap.mdx @@ -123,6 +123,13 @@ The Infisical LDAP dynamic secret allows you to generate user credentials on dem changetype: delete ``` + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx index 5eda1669e..5d27d16e2 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx @@ -6,11 +6,10 @@ description: "Learn how to dynamically generate Mongo Atlas Database user creden The Infisical Mongo Atlas dynamic secret allows you to generate Mongo Atlas Database credentials on demand based on configured role. ## Prerequisite -Create a project scopped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). - - The API Key must have permission to manage users in the project. - +Create a project scoped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). + +The API Key must have permission to manage users in the project. ## Set up Dynamic Secrets with Mongo Atlas @@ -29,86 +28,104 @@ Create a project scopped API Key with the required permission in your Mongo Atla Name by which you want the secret to be referenced - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) - + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + - - Maximum time-to-live for a generated secret - + + Maximum time-to-live for a generated secret + - - The public key of your generated Atlas API Key. This acts as a username. - + + The public key of your generated Atlas API Key. This acts as a username. + - - The private key of your generated Atlas API Key. This acts as a password. - + + The private key of your generated Atlas API Key. This acts as a password. + - - Unique 24-hexadecimal digit string that identifies your project. This is same as project id - + + Unique 24-hexadecimal digit string that identifies your project. This is same as project id + - - List that provides the pairings of one role with one applicable database. - - **Database Name**: Database to which the user is granted access privileges. - - **Collection**: Collection on which this role applies. - - **Role Name**: Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. - - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. - + + List that provides the pairings of one role with one applicable database. + - **Database Name**: Database to which the user is granted access privileges. + - **Collection**: Collection on which this role applies. + - **Role Name**: Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. + - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) - List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. - ![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) - - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. - - **Type**: Category of resource that this database user can access. +![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + + List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. + - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. + - **Type**: Category of resource that this database user can access. + + + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certficate. - + + If this step fails, you may have to add the CA certificate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) - When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. - + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) - ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) ## Renew Leases + To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) - Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx index ec384f7a9..f71922473 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx @@ -62,25 +62,32 @@ Create a user with the required permission in your MongoDB instance. This user w Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role. - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. - + A CA may be required if your DB requires it for incoming connections. + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-mongodb.png) - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certificate. + If this step fails, you may have to add the CA certificate. - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -95,14 +102,14 @@ Create a user with the required permission in your MongoDB instance. This user w - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index 2a279ce90..0a73bf129 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -62,7 +62,7 @@ Create a user with the required permission in your SQL instance. This user will Name of the database for which you want to create dynamic secrets - + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). @@ -71,22 +71,30 @@ Create a user with the required permission in your SQL instance. This user will - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements-mssql.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements-mssql.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -101,14 +109,14 @@ Create a user with the required permission in your SQL instance. This user will - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete the lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index f88a88d35..6f708ebba 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -61,29 +61,37 @@ Create a user with the required permission in your SQL instance. This user will Name of the database for which you want to create dynamic secrets - + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statement-mysql.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify SQL Statements Modal](/images/platform/dynamic-secrets/modify-sql-statement-mysql.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certificate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -98,14 +106,14 @@ Create a user with the required permission in your SQL instance. This user will - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) @@ -116,4 +124,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index c7b34bec9..02b379b98 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -61,7 +61,7 @@ Create a user with the required permission in your SQL instance. This user will Name of the database for which you want to create dynamic secrets - + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). @@ -70,20 +70,30 @@ Create a user with the required permission in your SQL instance. This user will + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statement-oracle.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -98,14 +108,14 @@ Create a user with the required permission in your SQL instance. This user will - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) @@ -116,4 +126,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index feb81d6d6..f13c9c762 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -62,7 +62,7 @@ Create a user with the required permission in your SQL instance. This user will Name of the database for which you want to create dynamic secrets - + A CA may be required if your DB requires it for incoming connections. AWS RDS instances with default settings will requires a CA which can be downloaded [here](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions). @@ -71,22 +71,30 @@ Create a user with the required permission in your SQL instance. This user will - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sql-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. This is useful if you want to only give access to a specific table(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certficate. + If this step fails, you may have to add the CA certificate. ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) @@ -101,14 +109,14 @@ Create a user with the required permission in your SQL instance. This user will - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete the lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) diff --git a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx index 6ac5ac069..09c04e61b 100644 --- a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx +++ b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx @@ -9,7 +9,6 @@ The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credential 1. Ensure that the `management` plugin is enabled on your RabbitMQ instance. This is required for the dynamic secret to work. - ## Set up Dynamic Secrets with RabbitMQ @@ -19,98 +18,113 @@ The Infisical RabbitMQ dynamic secret allows you to generate RabbitMQ credential ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-rabbit-mq.png) + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-rabbit-mq-modal.png) Name by which you want the secret to be referenced - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) - + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + - - Maximum time-to-live for a generated secret. - + + Maximum time-to-live for a generated secret. + - + Your RabbitMQ host. This must be in HTTP format. _(Example: http://your-cluster-ip)_ - + - - The port that the RabbitMQ management plugin is listening on. This is `15672` by default. - + - - The name of the virtual host that the user will be assigned to. This defaults to `/`. - +The port that the RabbitMQ management plugin is listening on. This is `15672` by default. + + + + The name of the virtual host that the user will be assigned to. This defaults + to `/`. + The permissions that the user will have on the virtual host. This defaults to `.*`. The three permission fields all take a regular expression _(regex)_, that should match resource names for which the user is granted read / write / configuration permissions + + + The username of the user that will be used to provision new dynamic secret + leases. + - - The username of the user that will be used to provision new dynamic secret leases. - + + The password of the user that will be used to provision new dynamic secret + leases. + - - The password of the user that will be used to provision new dynamic secret leases. - + +Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. - +Allowed template variables are +- `{{randomUsername}}`: Random username string +- `{{unixTimestamp}}`: Current Unix timestamp + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-rabbit-mq.png) + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + +![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-rabbit-mq.png) - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - - If this step fails, you may have to add the CA certificate. - + + If this step fails, you may have to add the CA certificate. + - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) - When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. - + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) - ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) ## Renew Leases + To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) - Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret diff --git a/docs/documentation/platform/dynamic-secrets/redis.mdx b/docs/documentation/platform/dynamic-secrets/redis.mdx index 43fbc6b61..b3e585204 100644 --- a/docs/documentation/platform/dynamic-secrets/redis.mdx +++ b/docs/documentation/platform/dynamic-secrets/redis.mdx @@ -56,21 +56,29 @@ Create a user with the required permission in your Redis instance. This user wil - If you want to provide specific privileges for the generated dynamic credentials, you can modify the Redis statement to your needs. This is useful if you want to only give access to a specific table(s). + ![Modify Redis Statements Modal](/images/platform/dynamic-secrets/modify-redis-statement.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - ![Modify Redis Statements Modal](/images/platform/dynamic-secrets/modify-redis-statement.png) + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the Redis statement to your needs. This is useful if you want to only give access to a specific table(s). + - After submitting the form, you will see a dynamic secret created in the dashboard. + After submitting the form, you will see a dynamic secret created in the dashboard. - If this step fails, you may have to add the CA certificate. + If this step fails, you may have to add the CA certificate. - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) @@ -85,14 +93,14 @@ Create a user with the required permission in your Redis instance. This user wil - Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) ## Audit or Revoke Leases -Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. This will allow you to see the expiration time of the lease or delete a lease before it's set time to live. ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) @@ -103,4 +111,4 @@ To extend the life of the generated dynamic secret leases past its initial time Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret - \ No newline at end of file + diff --git a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx index 3b7a895fb..2737ab084 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx @@ -62,21 +62,30 @@ The Infisical SAP ASE dynamic secret allows you to generate SAP ASE database cre ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-setup-modal.png) - - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. - - Due to SAP ASE limitations, the attached SQL statements are not executed as a transaction. - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + +If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + +Due to SAP ASE limitations, the attached SQL statements are not executed as a transaction. + + After submitting the form, you will see a dynamic secret created in the dashboard. - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) diff --git a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx index 668777549..597d69803 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx @@ -62,14 +62,25 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png) - - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. - ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png) + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. Due to SAP HANA limitations, the attached SQL statements are not executed as a transaction. + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -80,8 +91,8 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c - Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. - To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) diff --git a/docs/documentation/platform/dynamic-secrets/snowflake.mdx b/docs/documentation/platform/dynamic-secrets/snowflake.mdx index 75db96c8f..86378bbf6 100644 --- a/docs/documentation/platform/dynamic-secrets/snowflake.mdx +++ b/docs/documentation/platform/dynamic-secrets/snowflake.mdx @@ -8,22 +8,27 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ## Snowflake Prerequisites - Infisical requires a Snowflake user in your account with the USERADMIN role. This user will act as a service account for Infisical and facilitate the creation of new users as needed. + Infisical requires a Snowflake user in your account with the USERADMIN role. + This user will act as a service account for Infisical and facilitate the + creation of new users as needed. - - ![Snowflake User Dashboard](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-users-page.png) - - - - Be sure to uncheck "Force user to change password on first time login" - - ![Snowflake Create Service User](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-create-service-user.png) - - - ![Snowflake Account And Organization Identifiers](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-identifiers.png) - + + ![Snowflake User + Dashboard](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-users-page.png) + + + + Be sure to uncheck "Force user to change password on first time login" + + ![Snowflake Create Service + User](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-create-service-user.png) + + + ![Snowflake Account And Organization + Identifiers](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-identifiers.png) + ## Set up Dynamic Secrets with Snowflake @@ -71,10 +76,23 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede - If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL - statement to your needs. ![Modify SQL Statements Modal](/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png) - + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL + statement to your needs. + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. @@ -104,6 +122,7 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + ## Audit or Revoke Leases @@ -119,6 +138,6 @@ To extend the life of the generated dynamic secret lease past its initial time t ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) - Lease renewals cannot exceed the maximum TTL set when configuring the dynamic - secret. + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret. diff --git a/docs/documentation/platform/dynamic-secrets/vertica.mdx b/docs/documentation/platform/dynamic-secrets/vertica.mdx new file mode 100644 index 000000000..3d5c7b1a9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/vertica.mdx @@ -0,0 +1,134 @@ +--- +title: "Vertica" +description: "Learn how to dynamically generate Vertica database users." +--- + +The Infisical Vertica dynamic secret allows you to generate Vertica database credentials on demand based on configured role. + +## Prerequisite + +Create a user with the required permission in your Vertica instance. This user will be used to create new accounts on-demand. + +## Set up Dynamic Secrets with Vertica + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + + Vertica database host + + + + Vertica database port (default: 5433) + + + + Name of the Vertica database for which you want to create dynamic secrets + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + Customize the SQL statement used to create new users. Default creates a user with basic schema permissions. + + + Customize the SQL statement used to revoke users. Default revokes a user. + + + + + Length of generated passwords (1-250 characters) + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum symbols (default: 0) + + + Symbols allowed in generated passwords + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete the lease before its set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 83490fd4d..93a7f662f 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -89,22 +89,3 @@ The relay system provides secure tunneling: - Gateways only accept connections to approved resources - Each connection requires explicit project authorization - Resources remain private to their assigned organization - -## Security Measures - -### Certificate Lifecycle -- Certificates have limited validity periods -- Automatic certificate rotation -- Immediate certificate revocation capabilities - -### Monitoring and Verification -1. **Continuous Verification**: - - Regular heartbeat checks - - Certificate chain validation - - Connection state monitoring - -2. **Security Controls**: - - Automatic connection termination on verification failure - - Audit logging of all access attempts - - Machine identity based authentication - diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx new file mode 100644 index 000000000..6acdc1993 --- /dev/null +++ b/docs/documentation/platform/gateways/networking.mdx @@ -0,0 +1,168 @@ +--- +title: "Networking" +description: "Network configuration and firewall requirements for Infisical Gateway" +--- + +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. + +## Network Architecture + +The gateway uses a relay-based architecture to establish secure connections: + +1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol +2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud +3. All traffic is end-to-end encrypted using mutual TLS over QUIC + +## Required Network Connectivity + +### Outbound Connections (Required) + +The gateway requires the following outbound connectivity: + +| Protocol | Destination | Ports | Purpose | +|----------|-------------|-------|---------| +| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | + +### Relay Server IP Addresses + +Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. + + + + ``` + 54.235.197.91:49152-65535 + 18.215.196.229:49152-65535 + 3.222.120.233:49152-65535 + 34.196.115.157:49152-65535 + ``` + + + ``` + 3.125.237.40:49152-65535 + 52.28.157.98:49152-65535 + 3.125.176.90:49152-65535 + ``` + + + Please contact your Infisical account manager for dedicated relay server IP addresses. + + + + + These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + + +## Protocol Details + +### QUIC over UDP + +The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: + +- **Port 5349**: STUN/TURN over TLS (secure relay communication) +- **Built-in features**: Connection migration, multiplexing, reduced latency +- **Encryption**: TLS 1.3 with certificate pinning + +## Understanding Firewall Behavior with UDP + +Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. + + +### Connection Tracking + +Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: +- Automatically handles return responses +- Reduces firewall rule complexity +- Avoids the need for manual IP whitelisting + +In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. + +## Common Network Scenarios + +### Corporate Firewalls + +For corporate environments with strict egress filtering: + +1. **Whitelist relay IP addresses** (listed above) +2. **Allow UDP port 5349** outbound +3. **Configure connection tracking** for UDP return traffic +4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled + +### Cloud Environments (AWS/GCP/Azure) + +Configure security groups to allow: +- **Outbound UDP** to relay IPs on port 5349 +- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 +- **Inbound UDP** on ephemeral ports (if not using stateful rules) + +## Frequently Asked Questions + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers +- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + + +QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: + +- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time +- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default +- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) +- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other +- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery +- **Lower latency**: Optimized for real-time communication between gateway and cloud services + +While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. + + + +No inbound ports need to be opened. The gateway only makes outbound connections: + +- **Outbound UDP** to relay servers on ports 49152-65535 +- **Outbound HTTPS** to Infisical API endpoints +- **Return responses** are handled by connection tracking or explicit IP whitelisting + +This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. + + + +If your firewall has strict UDP restrictions: + +1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses +2. **Use explicit IP whitelisting** if connection tracking is disabled +3. **Consider network policy exceptions** for the gateway host +4. **Monitor firewall logs** to identify which specific rules are blocking traffic + +The gateway requires UDP connectivity to function - TCP-only configurations are not supported. + + + +The gateway connects to **one relay server at a time**: + +- **Single active connection**: Only one relay connection is established per gateway instance +- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay +- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing +- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity + +You should whitelist all relay IP addresses to ensure proper failover functionality. + + +No, relay servers cannot decrypt any traffic passing through them: + +- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning +- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys +- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation + +The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. + \ No newline at end of file diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index ae4a3c7ad..e5f9623f5 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -32,7 +32,7 @@ For detailed installation instructions, refer to the Infisical [CLI Installation To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. -### Deployment process +### Get started @@ -128,7 +128,7 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t - + For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: ```bash infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index 9daff1e81..e357ba75e 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -52,10 +52,11 @@ Infisical is able to authenticate and interact with the TokenReview API by using In the following steps, we explore how to create and use identities for your applications in Kubernetes to access the Infisical API using the Kubernetes Auth authentication method. + + - - + **When to use this option**: Choose this approach when you want centralized authentication management. Only one service account needs special permissions, and your application service accounts remain unchanged. @@ -126,41 +127,91 @@ In the following steps, we explore how to create and use identities for your app ``` Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + - - + + + **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. + - - **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. - + The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. - The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. + For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: - For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: + ```yaml client-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-client-binding-[your-app-name] + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: [your-app-service-account] + namespace: [your-app-namespace] + ``` - ```yaml client-role-binding.yaml - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: infisical-client-binding-[your-app-name] - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: system:auth-delegator - subjects: - - kind: ServiceAccount - name: [your-app-service-account] - namespace: [your-app-namespace] - ``` + ``` + kubectl apply -f client-role-binding.yaml + ``` - ``` - kubectl apply -f client-role-binding.yaml - ``` + When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. + + + + **When to use this option**: Choose this approach when you have a gateway deployed in your Kubernetes Cluster and wish to eliminate long-lived tokens. This approach simplifies Infisical Kubernetes Auth configuration, and only one service account will need to have the elevated `system:auth-delegator` ClusterRole binding. + - When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. - - - + + **Note:** Gateway is a paid feature. - **Infisical Cloud users:** Gateway is + available under the **Enterprise Tier**. - **Self-Hosted Infisical:** Please + contact [sales@infisical.com](mailto:sales@infisical.com) to purchase an + enterprise license. + + + + + To deploy a gateway in your Kubernetes cluster, follow our [Gateway deployment guide using helm](/documentation/platform/gateways/overview). + + + + To grant the gateway the `system:auth-delegator` ClusterRole binding, you can use the following command: + + ```yaml gateway-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-token-reviewer-role-binding + namespace: default # Replace with your namespace if not default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-gateway # The name of the gateway service account + namespace: default # Replace with your namespace if not default + ``` + + ```bash + kubectl apply -f gateway-role-binding.yaml + ``` + + + The gateway service account name is `infisical-gateway` by default if deployed using Helm. + + + + + To configure your Kubernetes Auth method to use the gateway as the token reviewer, set the `Review Method` to "Gateway as Reviewer", and select the gateway you want to use as the token reviewer. + + ![identities organization create kubernetes auth method](/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png) + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. diff --git a/docs/documentation/platform/identities/oidc-auth/spire.mdx b/docs/documentation/platform/identities/oidc-auth/spire.mdx new file mode 100644 index 000000000..b402a1d10 --- /dev/null +++ b/docs/documentation/platform/identities/oidc-auth/spire.mdx @@ -0,0 +1,177 @@ +--- +title: SPIFFE/SPIRE +description: "Learn how to authenticate SPIRE workloads with Infisical using OpenID Connect (OIDC)." +--- + +**OIDC Auth** is a platform-agnostic JWT-based authentication method that can be used to authenticate from any platform or environment using an identity provider with OpenID Connect. + +## Diagram + +The following sequence diagram illustrates the OIDC Auth workflow for authenticating SPIRE workloads with Infisical. + +```mermaid +sequenceDiagram + participant Client as SPIRE Workload + participant Agent as SPIRE Agent + participant Server as SPIRE Server + participant Infis as Infisical + + Client->>Agent: Step 1: Request JWT-SVID + Agent->>Server: Validate workload and fetch signing key + Server-->>Agent: Return signing material + Agent-->>Client: Return JWT-SVID with verifiable claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send JWT-SVID to /api/v1/auth/oidc-auth/login + + Note over Infis,Server: Step 3: Query verification + Infis->>Server: Request JWT public key using OIDC Discovery + Server-->>Infis: Return public key + + Note over Infis: Step 4: JWT validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 5: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates a SPIRE workload by verifying the JWT-SVID and checking that it meets specific requirements (e.g. it is issued by a trusted SPIRE server) at the `/api/v1/auth/oidc-auth/login` endpoint. If successful, +then 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 SPIRE workload requests a JWT-SVID from the local SPIRE Agent. +2. The SPIRE Agent validates the workload's identity and requests signing material from the SPIRE Server. +3. The SPIRE Agent returns a JWT-SVID containing the workload's SPIFFE ID and other claims. +4. The JWT-SVID is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint. +5. Infisical fetches the public key that was used to sign the JWT-SVID from the SPIRE Server using OIDC Discovery. +6. Infisical validates the JWT-SVID using the public key provided by the SPIRE Server and checks that the subject, audience, and claims of the token matches with the set criteria. +7. If all is well, Infisical returns a short-lived access token that the workload can use to make authenticated requests to the Infisical API. + +Infisical needs network-level access to the SPIRE Server's OIDC Discovery endpoint. + +## Prerequisites + +Before following this guide, ensure you have: + +- A running SPIRE deployment with both SPIRE Server and SPIRE Agent configured +- OIDC Discovery Provider deployed alongside your SPIRE Server +- Workload registration entries created in SPIRE for the workloads that need to access Infisical +- Network connectivity between Infisical and your OIDC Discovery Provider endpoint + +For detailed SPIRE setup instructions, refer to the [SPIRE documentation](https://spiffe.io/docs/latest/spire-about/). + +## OIDC Discovery Provider Setup + +To enable JWT-SVID verification with Infisical, you need to deploy the OIDC Discovery Provider alongside your SPIRE Server. The OIDC Discovery Provider runs as a separate service that exposes the necessary OIDC endpoints. + +In Kubernetes deployments, this is typically done by adding an `oidc-discovery-provider` container to your SPIRE Server StatefulSet: + +```yaml +- name: spire-oidc + image: ghcr.io/spiffe/oidc-discovery-provider:1.12.2 + args: + - -config + - /run/spire/oidc/config/oidc-discovery-provider.conf + ports: + - containerPort: 443 + name: spire-oidc-port +``` + +The OIDC Discovery Provider will expose the OIDC Discovery endpoint at `https:///.well-known/openid_configuration`, which Infisical will use to fetch the public keys for JWT-SVID verification. + +For detailed setup instructions, refer to the [SPIRE OIDC Discovery Provider documentation](https://github.com/spiffe/spire/tree/main/support/oidc-discovery-provider). + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method with SPIFFE/SPIRE. + + + + To create an identity, head to your Organization Settings > Access Control > 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. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization 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 by default, you should re-configure it to use OIDC Auth instead. To do this, press to edit the **Authentication** section, + remove the existing Universal Auth configuration, and add a new OIDC Auth configuration onto the identity. + + ![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + + ![identities create oidc auth method](/images/platform/identities/identities-org-create-oidc-auth-method.png) + + Restrict access by configuring the Subject, Audiences, and Claims fields + + Here's some more guidance on each field: + - OIDC Discovery URL: The URL used to retrieve the OpenID Connect configuration from the SPIRE Server. This will be used to fetch the public key needed for verifying the provided JWT-SVID. This should be set to your SPIRE Server's OIDC Discovery endpoint, typically `https://:/.well-known/openid_configuration` + - Issuer: The unique identifier of the SPIRE Server issuing the JWT-SVID. This value is used to verify the iss (issuer) claim in the JWT-SVID to ensure the token is issued by a trusted SPIRE Server. This should match your SPIRE Server's configured issuer, typically `https://:` + - CA Certificate: The PEM-encoded CA certificate for establishing secure communication with the SPIRE Server endpoints. This should contain the CA certificate that signed your SPIRE Server's TLS certificate. + - Subject: The expected SPIFFE ID that is the subject of the JWT-SVID. The format of the sub field for SPIRE JWT-SVIDs follows the SPIFFE ID format: `spiffe:///`. For example: `spiffe://example.org/workload/api-server` + - Audiences: A list of intended recipients for the JWT-SVID. This value is checked against the aud (audience) claim in the token. When workloads request JWT-SVIDs from SPIRE, they specify an audience (e.g., `infisical` or your service name). Configure this to match what your workloads use. + - Claims: Additional information or attributes that should be present in the JWT-SVID for it to be valid. Standard SPIRE JWT-SVID claims include `sub` (SPIFFE ID), `aud` (audience), `exp` (expiration), and `iat` (issued at). You can also configure custom claims if your SPIRE Server includes additional metadata. + - 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 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. + SPIRE JWT-SVIDs contain standard claims like `sub` (SPIFFE ID), `aud` (audience), `exp`, and `iat`. The audience is typically specified when requesting the JWT-SVID (e.g., `spire-agent api fetch jwt -audience infisical`). + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded SPIFFE IDs whenever possible for better security. + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + Here's an example of how a workload can use its JWT-SVID to authenticate with Infisical and retrieve secrets: + + ```bash + #!/bin/bash + + # Obtain JWT-SVID from SPIRE Agent + JWT_SVID=$(spire-agent api fetch jwt -audience infisical -socketPath /run/spire/sockets/agent.sock | grep -A1 "token(" | tail -1) + + # Authenticate with Infisical using the JWT-SVID + ACCESS_TOKEN=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "{\"identityId\":\"\",\"jwt\":\"$JWT_SVID\"}" \ + https://app.infisical.com/api/v1/auth/oidc-auth/login | jq -r '.accessToken') + + # Use the access token to retrieve secrets + curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "https://app.infisical.com/api/v3/secrets/raw?workspaceSlug=&environment=&secretPath=/" + ``` + + + 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 authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + JWT-SVIDs from SPIRE have their own expiration time (typically short-lived). Ensure your application handles both JWT-SVID renewal from SPIRE and access token renewal from Infisical appropriately. + + + + \ No newline at end of file diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index 33a28305f..c7d4d32fa 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -29,7 +29,6 @@ Using a hardware security module comes with the added benefit of having a secure Enabling HSM encryption has a set of key benefits: 1. **Root Key Wrapping**: The root KMS encryption key that is used to secure your Infisical instance will be encrypted using the HSM device rather than the standard software-protected key. -2. **FIPS 140-2/3 Compliance**: Using an HSM device ensures that your Infisical instance is FIPS 140-2 or FIPS 140-3 compliant. For FIPS 140-3, ensure that your HSM is FIPS 140-3 validated. #### Caveats - **Performance**: Using an HSM device can have a performance impact on your Infisical instance. This is due to the additional latency introduced by the HSM device. This is however only noticeable when your instance(s) start up or when the encryption strategy is changed. @@ -41,13 +40,6 @@ Enabling HSM encryption has a set of key benefits: - An HSM device from a provider such as [Thales Luna HSM](https://cpl.thalesgroup.com/encryption/data-protection-on-demand/services/luna-cloud-hsm), [AWS CloudHSM](https://aws.amazon.com/cloudhsm/), [Fortanix HSM](https://www.fortanix.com/platform/data-security-manager), or others. -### FIPS Compliance -FIPS, also known as the Federal Information Processing Standard, is a set of standards that are used to accredit cryptographic modules. FIPS 140-2 and FIPS 140-3 are the two most common standards used for cryptographic modules. If your HSM uses FIPS 140-3 validated hardware, Infisical will automatically be FIPS 140-3 compliant. If your HSM uses FIPS 140-2 validated hardware, Infisical will be FIPS 140-2 compliant. - -HSM devices are especially useful for organizations that operate in regulated industries such as healthcare, finance, and government, where data security and compliance are of the utmost importance. - -For organizations that work with US government agencies, FIPS compliance is almost always a requirement when dealing with sensitive information. FIPS compliance ensures that the cryptographic modules used by the organization meet the security requirements set by the US government. - ## Setup Instructions diff --git a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx new file mode 100644 index 000000000..2a04f5e3a --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -0,0 +1,39 @@ +--- +title: "Gloo Mesh Integration" +description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" +--- + +This guide will provide a high level overview on how you can use Infisical PKI and cert-manager to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). + +## Overview + +In this setup, we will use Infisical PKI to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. +To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). +Cert-manager is a Kubernetes controller that helps you automate the process of obtaining and renewing certificates from various PKI providers. + +With this approach, you get the following benefits: + +- Securely store your root CA certificates and private keys. +- Leverage Infisical subordinate CAs for an extra layer of protection beneath your root CA. +- Use cert-manager to automatically issue and renew Istio intermediate CA certificates from the same root, ensuring cross-cluster workload communication. +- Increased auditability of private key infrastructure. + + +## General Setup +The certificate provisioning workflow begins with setting up your PKI hierarchy in Infisical, where you create root and subordinate certificate authorities. +When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the Infisical PKI Issuer controller to authenticate with Infisical using machine identity credentials and request an intermediate CA certificate. +Infisical verifies the request against your certificate templates and returns the signed certificate. +From there, Istio's control plane will automatically use this intermediate CA to sign leaf certificates for workloads in the service mesh, enabling secure mTLS communication across your entire Gloo Mesh infrastructure. + +Follow the [Infisical PKI Issuer guide](/documentation/platform/pki/pki-issuer) for detailed instructions on how to set up the Infisical PKI Issuer and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. + +For Gloo Mesh-specific configuration, ensure that: + +- The Certificate resource targets the `istio-system` namespace with `secretName: cacerts` +- Certificate templates in Infisical PKI are configured for intermediate CA usage with appropriate key usage and constraints +- Multiple workload clusters use the same Infisical PKI root to enable cross-cluster mTLS communication + +## Using the certificates + +Once the `cacerts` Kubernetes secret is created in the `istio-system` namespace, Istio automatically uses the custom CA certificate instead of the default self-signed certificate. +When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical PKI intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. \ No newline at end of file diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx index c02e477c6..c46c1f35e 100644 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ b/docs/documentation/platform/pki/pki-issuer.mdx @@ -1,7 +1,6 @@ --- -title: "Kubernetes Issuer" -sidebarTitle: "Certificates for Kubernetes" -description: "Learn how to automatically provision and manage TLS certificates for in Kubernetes using Infisical PKI" +title: "Cert Manager Issuer" +description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" --- ## Concept @@ -21,20 +20,21 @@ A typical workflow for using the Infisical PKI Issuer to issue certificates for 3. Installing `cert-manager` into your Kubernetes cluster. 4. Installing the Infisical PKI Issuer controller into your Kubernetes cluster. 5. Creating an `Issuer` or `ClusterIssuer` resource in your Kubernetes cluster to represent the Infisical PKI issuer you wish to use. -6. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. -7. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. +6. Create the approver policy to accept certificate request. +7. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. +8. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. ## Guide -In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. +In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. - + Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - + By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the Infisical PKI Issuer to authenticate with Infisical; this will be useful in steps 4 and 5. - + Currently, the Infisical PKI Issuer only supports authenticating with Infisical via the [Universal Auth](/documentation/platform/identities/universal-auth) authentication method. @@ -43,14 +43,14 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Install `cert-manager` into your Kubernetes cluster by following the instructions [here](https://cert-manager.io/docs/installation/) or by running the following command: - + ```bash kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml ``` Install the Infisical PKI Issuer controller into your Kubernetes cluster by running the following command: - + ```bash kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml ``` @@ -76,7 +76,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using data: clientSecret: ``` - + ```bash kubectl apply -f secret-issuer.yaml ``` @@ -84,7 +84,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using - Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, either `caId` or `certificateTemplateId`, and applying the following configuration file for the `Issuer` resource. + Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, `projectId` or `certificateTemplateName`, and applying the following configuration file for the `Issuer` resource. This configuration file specifies the connection details to your Infisical PKI CA to be used for issuing certificates. ```yaml infisical-issuer.yaml @@ -95,8 +95,8 @@ In the following steps, we explore how to install the Infisical PKI Issuer using namespace: spec: url: "https://app.infisical.com" # the URL of your Infisical instance - caId: # the ID of the CA you want to use to issue certificates - certificateTemplateId: # the ID of the certificate template you want to use to issue certificates against + projectId: # the ID of the project you want to use to issue certificates + certificateTemplateName: # the name of the certificate template you want to use to issue certificates against authentication: universalAuth: clientId: # the Client ID from step 1 @@ -104,20 +104,11 @@ In the following steps, we explore how to install the Infisical PKI Issuer using name: "issuer-infisical-client-secret" key: "clientSecret" ``` - + ``` kubectl apply -f infisical-issuer.yaml ``` - - - The Infisical PKI Issuer supports issuing certificates against a specific CA or a specific certificate template. - - For this reason, you should only fill in the `caId` or the `certificateTemplateId` field but not both. - - We recommend using the `certificateTemplateId` field to issue certificates against a specific [certificate template](/documentation/platform/pki/certificate-templates) - since templates let you enforce constraints on issued certificates and may have alerting policies bound to them. - - + You can check that the issuer was created successfully by running the following command: ```bash @@ -128,16 +119,60 @@ In the following steps, we explore how to install the Infisical PKI Issuer using NAME AGE issuer-infisical 21h ``` - + An `Issuer` is a namespaced resource, and it is not possible to issue certificates from an `Issuer` in a different namespace. This means you will need to create an `Issuer` in each namespace you wish to obtain `Certificates` in. If you want to create a single `Issuer` that can be consumed in multiple namespaces, you should consider creating a `ClusterIssuer` resource. This is almost identical to the `Issuer` resource, however is non-namespaced so it can be used to issue `Certificates` across all namespaces. - + You can read more about the `Issuer` and `ClusterIssuer` resources [here](https://cert-manager.io/docs/configuration/). + + If you create a `CertificateRequest` now, you'll notice it's neither approved nor denied. This is expected because by default cert-manager approver controller requires an approver-policy. + + To enable approval, create the following YAML file and apply it: + + ```yaml infisical-approver-policy.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: infisical-issuer-approver + rules: + # Permission to approve or deny CertificateRequests for signers in cert-manager.io API group + - apiGroups: ['cert-manager.io'] + resources: ['signers'] + verbs: ['approve'] + resourceNames: + # Grant approval permissions for namespaced issuers + - "issuers.infisical-issuer.infisical.com/default.issuer-infisical" + # Grant approval permissions for cluster-scoped issuers + - "clusterissuers.infisical-issuer.infisical.com/clusterissuer-infisical" + --- + # Bind the cert-manager service account to the new role + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-issuer-approver-binding + subjects: + - kind: ServiceAccount + name: cert-manager + namespace: cert-manager + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-issuer-approver + ``` + + ``` + kubectl apply -f infisical-approver-policy.yaml + ``` + + This configuration creates a `ClusterRole` named `infisical-issuer-approver` that grants approval permissions for specific Infisical issuer types. It then binds this role to the cert-manager service account, allowing it to approve certificate requests from your Infisical issuers. + + For information, check out [cert manager approval policy doc](https://cert-manager.io/docs/policy/approval/approver-policy/). + Finally, create a `Certificate` by applying the following configuration file. @@ -162,7 +197,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using duration: 48h # the ttl for the certificate renewBefore: 12h # the time before the certificate expiry that the certificate should be automatically renewed ``` - + The above sample configuration file specifies a certificate to be issued with the common name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. @@ -181,7 +216,7 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: - + ```bash kubectl get secret certificate-by-issuer -n ``` @@ -190,9 +225,9 @@ In the following steps, we explore how to install the Infisical PKI Issuer using NAME TYPE DATA AGE certificate-by-issuer kubernetes.io/tls 2 26h ``` - + We can `describe` the secret to get more information about it: - + ```bash kubectl describe secret certificate-by-issuer -n default ``` @@ -201,14 +236,14 @@ In the following steps, we explore how to install the Infisical PKI Issuer using Name: certificate-by-issuer Namespace: default Labels: controller.cert-manager.io/fao=true - Annotations: cert-manager.io/alt-names: + Annotations: cert-manager.io/alt-names: cert-manager.io/certificate-name: certificate-by-issuer cert-manager.io/common-name: certificate-by-issuer.example.com - cert-manager.io/ip-sans: + cert-manager.io/ip-sans: cert-manager.io/issuer-group: infisical-issuer.infisical.com cert-manager.io/issuer-kind: Issuer cert-manager.io/issuer-name: issuer-infisical - cert-manager.io/uri-sans: + cert-manager.io/uri-sans: Type: kubernetes.io/tls @@ -218,17 +253,18 @@ In the following steps, we explore how to install the Infisical PKI Issuer using tls.crt: 2380 bytes tls.key: 227 bytes ``` - + Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. - + We can decode the certificate and print it out using `openssl`: ```bash kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout ``` - + In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. + ## FAQ @@ -236,15 +272,24 @@ In the following steps, we explore how to install the Infisical PKI Issuer using The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - + Currently, not all fields are supported by the Infisical PKI Issuer. + Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as specified in the corresponding `Certificate` resource. - + You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + - \ No newline at end of file + + If you see log messages similar to: + ``` + "CertificateRequest has not been approved yet. Ignoring.","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","CertificateRequest":{"name":"skynet-infisical-rta-rsa2048-1","namespace":"infisical-system"},"namespace":"infisical-system","name":"skynet-infisical-rta-rsa2048-1","reconcileID":"bfb7cad9-d867-45b5-b3a3-0139e731b7a6"} + ``` + This indicates that the `CertificateRequest` has been created, but `cert-manager` has not yet approved it. This typically occurs because a necessary approver policy is missing. Refer to the documentation above to create an approver policy. + + diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index 610d1fd47..c248c4dfa 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -37,6 +37,10 @@ The enforcement level determines how strict the policy is. A **Hard** enforcemen Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**. +If you choose to allow approval bypasses (Soft Enforcement), you may select specific users or groups that can perform the bypass for that specific policy. Not choosing users or groups will allow anyone to bypass the policy. + +A policy bypasser cannot bypass requests from others; the bypass action can only be performed by the request creator. + ### Self approvals If the **Self Approvals** option is enabled, users who are designated as approvers on the policy can approve requests that they themselves have submitted. diff --git a/docs/documentation/platform/secret-rotation/mysql-credentials.mdx b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx new file mode 100644 index 000000000..d0088a29e --- /dev/null +++ b/docs/documentation/platform/secret-rotation/mysql-credentials.mdx @@ -0,0 +1,158 @@ +--- +title: "MySQL Credentials Rotation" +description: "Learn how to automatically rotate MySQL credentials." +--- + +## Prerequisites + +1. Create a [MySQL Connection](/integrations/app-connections/mysql) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. + + An example creation statement might look like: + ```SQL + -- create user roles + CREATE USER 'infisical_user_1'@'%' IDENTIFIED BY 'temporary_password'; + CREATE USER 'infisical_user_2'@'%' IDENTIFIED BY 'temporary_password'; + + -- grant all privileges + GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user_1'@'%'; + GRANT ALL PRIVILEGES ON my_database.* TO 'infisical_user_2'@'%'; + + -- apply the privilege changes + FLUSH PRIVILEGES; + ``` + + + To learn more about the MySQL permission system, please visit their [documentation](https://dev.mysql.com/doc/refman/8.4/en/grant.html). + + + +## Create a MySQL Credentials Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **MySQL Credentials** option. + ![Select MySQL Credentials](/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png) + + 3. Select the **MySQL Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png) + + - **MySQL Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png) + + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png) + + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png) + + 8. Your **MySQL Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png) + + + To create a MySQL Credentials Rotation, make an API request to the [Create MySQL Credentials Rotation](/api-reference/endpoints/secret-rotations/mysql-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/mysql-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mysql-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "MYSQL_USERNAME", + "password": "MYSQL_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mysql-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "MYSQL_USERNAME", + "password": "MYSQL_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "mysql", + "name": "my-mysql-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "mysql-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-scanning.mdx b/docs/documentation/platform/secret-scanning.mdx deleted file mode 100644 index da28bfa55..000000000 --- a/docs/documentation/platform/secret-scanning.mdx +++ /dev/null @@ -1,175 +0,0 @@ ---- -title: 'Secret Scanning' -description: "Scan and prevent secret leaks in your code repositories" ---- - -The Infisical Secret Scanner allows you to keep an overview and stay alert of exposed secrets across your entire GitHub organization and repositories. - -To further enhance security, we recommend you also use our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to scan for exposed secrets prior to pushing your changes. - - - - - To setup secret scanning on your own instance of Infisical, you can follow the steps below. - - - - Create a new GitHub app in your GitHub organization or personal [Developer Settings](https://github.com/settings/apps). - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app.png) - - ### Configure the GitHub App - To configure the GitHub app to work with Infisical, you'll need to modify the following settings: - - **Homepage URL**: Required to be set. Set it to the URL of your Infisical instance. (e.g. `https://app.infisical.com`) - - **Setup URL**: Set this to `https:///organization/secret-scanning` - - **Webhook URL**: Set this to `https:///api/v1/secret-scanning/webhook` - - **Webhook Secret**: Set this to a random string. This is used to verify the webhook request from Infisical. Use `openssl rand -base64 32` in your terminal to generate a random secret. - - - Remember to save the webhook secret as you will need it in the next step. - - - ![GitHub App Settings](/images/platform/secret-scanning/github-configure-app.png) - - ### Configure the GitHub App Permissions - The GitHub app needs the following permissions: - - Repository permissions: - - `Checks`: Read and Write - - `Contents`: Read-only - - `Issues`: Read and Write - - `Pull Requests`: Read and Write - - `Metadata`: Read-only (enabled by default) - - ![Github App Repository Permissions](/images/platform/secret-scanning/github-repo-permissions.png) - - Subscribed events: - - `Check run` - - `Pull request` - - `Push` - - ![Github App Subscribed Events](/images/platform/secret-scanning/github-subscribed-events.png) - - - ### Create the GitHub App - Now you can create the GitHub app by clicking on the "Create GitHub App" button. - - - If you want other Github users to be able to install the app, you need to tick the "Any account" option under "Where can this GitHub App be installed?" - - - ![Create GitHub App](/images/platform/secret-scanning/github-create-app-button.png) - - - - After clicking the "Create GitHub App" button, you will be redirected to the GitHub settings page. Here you can copy the "App ID" and save it for later when you need to configure your environment variables for your Infisical instance. - - ![Github App ID](/images/platform/secret-scanning/github-app-copy-app-id.png) - - - - The GitHub App slug is the name of the app you created in a slug friendly format. You can find the slug in the URL of the app you created. - - ![Github App Slug](/images/platform/secret-scanning/github-app-copy-slug.png) - - - - Create a new app private key by clicking on the "Generate a private key" button under the "Private keys" section. - - Once you click the "Generate a private key" button, the private key will be downloaded to your computer. Save this file for later as you will need the private key when configuring Infisical. - - ![Github App Private Key](/images/platform/secret-scanning/github-app-create-private-key.png) - - - Remember to save the private key as you will need it in the next step. - - - - - - - Now you can configure your Infisical instance by setting the following environment variables: - - - `SECRET_SCANNING_GIT_APP_ID`: The App ID of your GitHub App. - - `SECRET_SCANNING_GIT_APP_SLUG`: The slug of your GitHub App. - - `SECRET_SCANNING_PRIVATE_KEY`: The private key of your GitHub App that you created in a previous step. - - `SECRET_SCANNING_WEBHOOK_SECRET`: The webhook secret of your GitHub App that you created in a previous step. - - - - After restarting your Infisical instance, you should be able to use the secret scanning feature within your organization. Follow the steps below to add the GitHub App to your Infisical organization. - - -## Install the Infisical Radar GitHub App - -To install the GitHub App, press the "Integrate With GitHub" button in the top right corner of your Infisical Secret Scanning dashboard. - -![Integrate With GitHub](/images/platform/secret-scanning/infisical-connect-secret-scanner.png) - -Next, you'll be prompted to select which organization you'd like to install the app into. Select the organization you'd like to install the app into by clicking the organization in the menu. - -![Select Organization](/images/platform/secret-scanning/github-select-org-2.png) - -Select the repositories you'd like to scan for secrets and press the "Install" button. - -![Select Repositories](/images/platform/secret-scanning/github-select-repos.png) - -## Code Scanning - -![Scanning Overview](/images/platform/secret-scanning/overview.png) - -Secret scans are built on event-driven architecture. This means that every time a push is made to one of your selected repositories, Infisical will scan the modified files for any exposed secrets. - -If one or more exposed secrets are detected, it will be displayed in your Infisical dashboard. An exposed secret is known as a **"Risk"**. Each risk has the following data associated with it: -- **Date**: When the risk was first detected. -- **Secret Type**: Which type of secret was detected. -- **Info**: Information about the secret, such as the repository, file name, and the committer who made the change. - -Once an exposed secret is detected, all organization admins will be sent an e-mail notification containing details about the exposed secret. - - - Each risk also contains a "View Exposed Secret" button, which will take you directly to the GitHub commit and to the line where the secret was exposed. - - - - -![Exposed Secret](/images/platform/secret-scanning/exposed-secret.png) - - -## Responding to Exposed Secrets - -After an exposed secret is detected, it will be marked as `Needs Attention`. When there are risks marked as needs attention, it's important to address them as soon as possible. - -You can mark the risk as `Resolved` by changing the status to one of the following states: -- **This Is a False Positive**: The secret was not exposed, but was detected by the scanner. -- **I Have Rotated The Secret**: The secret was exposed, but it has now been removed. -- **No Rotation Needed**: You are choosing to ignore this risk. You may choose to do this if the risk is non-sensitive or otherwise not a security risk. - -![Needs Attention](/images/platform/secret-scanning/needs-attention.png) - - - - -## Ignoring Known Secrets -If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. - -### infisical-scan:ignore - -To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. - -```js example.js -function helloWorld() { - console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore -} -``` - -### .infisicalignore -An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. -You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. -By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. - -```.ignore .infisicalignore -bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 -bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 -1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 -``` diff --git a/docs/documentation/platform/secret-scanning/github.mdx b/docs/documentation/platform/secret-scanning/github.mdx new file mode 100644 index 000000000..9fa766ed6 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/github.mdx @@ -0,0 +1,96 @@ +--- +title: "GitHub Secret Scanning" +sidebarTitle: "GitHub" +description: "Learn how to configure secret scanning for GitHub." +--- + +## Prerequisites + +- Create a [GitHub Radar Connection](/integrations/app-connections/github-radar) + +## Create a GitHub Data Source in Infisical + + + + 1. Navigate to your Secret Scanning Project's Dashboard and click the **Add Data Source** button. + ![Secret Scanning Dashboard](/images/platform/secret-scanning/github/github-data-source-step-1.png) + + 2. Select the **GitHub** option. + ![Select GitHub Option](/images/platform/secret-scanning/github/github-data-source-step-2.png) + + 3. Select the **GitHub Radar Connection** to use and configure which repositories you would like to scan. Then click **Next**. + ![Data Source Configuration](/images/platform/secret-scanning/github/github-data-source-step-3.png) + + - **GitHub Radar Connection** - the connection that has access to the repositories you want to scan. + - **Scan Repositories** - select which repositories you would like to scan. + - **All Repositories** - Infisical will scan all repositories associated with your connection. + - **Select Repositories** - Infisical will scan the selected repositories. + - **Auto-Scan Enabled** - whether Infisical should automatically perform a scan when a push is made to configured repositories. + + 4. Give your data source a name and description (optional). Then click **Next**. + ![Data Source Details](/images/platform/secret-scanning/github/github-data-source-step-4.png) + + - **Name** - the name of the data source. Must be slug-friendly. + - **Description** (optional) - a description of this data source. + + 5. Review your data source, then click **Create Data Source**. + ![Data Source Review](/images/platform/secret-scanning/github/github-data-source-step-5.png) + + 6. Your **GitHub Data Source** is now available and will begin a full scan if **Auto-Scan** is enabled. + ![Data Source Created](/images/platform/secret-scanning/github/github-data-source-step-6.png) + + 7. You can view repositories and scan results by clicking on your data source. + ![Data Source Page](/images/platform/secret-scanning/github/github-data-source-step-7.png) + + 8. In addition, you can review any findings from the **Findings Page**. + ![Findings Page](/images/platform/secret-scanning/github/github-data-source-step-8.png) + + + To create a GitHub Data Source, make an API request to the [Create GitHub Data Source](/api-reference/endpoints/secret-scanning/data-sources/github/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-scanning/data-sources/github \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-github-source", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my github data source", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "isAutoScanEnabled": true, + "config": { + "includeRepos": ["*"] + } + }' + ``` + + ### Sample response + + ```bash Response + { + "dataSource": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "externalId": "1234567890", + "name": "my-github-source", + "description": "my github data source", + "isAutoScanEnabled": true, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "type": "github", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "github-radar", + "name": "my-radar-app", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "config": { + "includeRepos": ["*"] + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-scanning/overview.mdx b/docs/documentation/platform/secret-scanning/overview.mdx new file mode 100644 index 000000000..69bf5a783 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/overview.mdx @@ -0,0 +1,230 @@ +--- +title: "Secret Scanning" +sidebarTitle: "Overview" +description: "Scan and prevent secret leaks in your code repositories" +--- + +## Introduction + +Monitor and detect exposed secrets across your data sources, including code repositories, with Infisical Secret Scanning. + +For additional security, we recommend using our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to check for exposed secrets before pushing your code changes. + + + Secret Scanning is a paid feature. + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## How Secret Scanning Works + +Secret Scanning consists of several components that enable you to quickly respond to secret leaks: + +- **Scanner Engine**: The core component that analyzes your code and detects potential secrets using pattern matching and entropy analysis +- **Real-time Monitoring**: Provides continuous surveillance of your repositories for immediate detection of exposed secrets +- **Alert System**: Notifies organization admins via email when secrets are detected +- **Risk Management**: Allows tracking and managing detected secrets with different status options +- **Data Sources**: Integrates with various data sources and version control systems +- **Customizable Rules**: Supports ignore patterns and custom configurations to reduce false positives + +These components work together to provide comprehensive secret detection and incident response capabilities. + +### Data Sources + +Data sources are configured integrations with external platforms, such as a GitHub organization or a GitLab group, that establish secure connections for scanning purposes using [App Connections](/integrations/app-connections/overview). + +A data source acts as a secure intermediary between the external system and the scanner engine. It manages a collection of scannable resources (such as repositories) and handles the authentication and communication required for scanning operations. + +![data sources](/images/platform/secret-scanning/secret-scanning-data-sources.png) + +### Resources + +Resources are the atomic, scannable units, such as a repository, that can be monitored for secret exposure. Resources are added automatically when a data source is scanned and updated when scanning events are triggered, such as when a user pushes changes to GitHub. + +Each resource maintains its own scanning history and status, allowing for granular monitoring and management of secret scanning across your organization. + +![resources](/images/platform/secret-scanning/secret-scanning-resources.png) + +### Scans + +Scans can be initiated in two ways: + +1. **Full Scan** - Manually triggered scan that comprehensively checks either all resources associated with a data source or a single selected resource. + +2. **Diff Scan** - Automatically executed when **Auto-Scan** is enabled on a data source. This scan type specifically focuses on updates to existing resources. + +All scan activities can be monitored in real-time through the Infisical UI, which displays: +- Current scan status +- Timestamp of the scan +- Resource(s) being scanned +- Detection results (whether any secrets were found) + +![scans](/images/platform/secret-scanning/secret-scanning-scans.png) + +### Findings + +Findings are automatically generated when secret leaks are detected during scanning operations. Each finding contains comprehensive information including: +- The specific scanning rule that identified the leak +- File location and line number where the secret was found +- Resource-specific details (e.g., commit hash and author for Git repositories) + +Findings are initially marked as **Unresolved** and can be updated to one of the following statuses with additional remarks: +- **Resolved** - The issue has been addressed +- **False Positive** - The detection was incorrect +- **Ignore** - The finding can be safely disregarded + +These status options help teams effectively track and manage the lifecycle of detected secret leaks. + +![findings](/images/platform/secret-scanning/secret-scanning-findings.png) + +### Configuration + +You can configure custom scanning rules and exceptions by updating your project's scanning configuration via the UI or API. + +The configuration options allow you to: +- Define custom scanning patterns and rules +- Set up ignore patterns to reduce false positives +- Specify file path exclusions +- Configure entropy thresholds for secret detection +- Add allowlists for known safe patterns + +For detailed configuration options, expand the example configuration below. + + + ```toml + # Title for the configuration file + title = "Some title" + + + # This configuration is the foundation that can be expanded. If there are any overlapping rules + # between this base and the expanded configuration, the rules in this base will take priority. + # Another aspect of extending configurations is the ability to link multiple files, up to a depth of 2. + # "Allowlist" arrays get appended and may have repeated elements. + # "useDefault" and "path" cannot be used simultaneously. Please choose one. + [extend] + # useDefault will extend the base configuration with the default config: + # https://raw.githubusercontent.com/Infisical/infisical/main/cli/config/infisical-scan.toml + useDefault = true + # or you can supply a path to a configuration. Path is relative to where infisical cli + # was invoked, not the location of the base config. + path = "common_config.toml" + + # An array of tables that contain information that define instructions + # on how to detect secrets + [[rules]] + + # Unique identifier for this rule + id = "some-identifier-for-rule" + + # Short human readable description of the rule. + description = "awesome rule 1" + + # Golang regular expression used to detect secrets. Note Golang's regex engine + # does not support lookaheads. + regex = '''one-go-style-regex-for-this-rule''' + + # Golang regular expression used to match paths. This can be used as a standalone rule or it can be used + # in conjunction with a valid `regex` entry. + path = '''a-file-path-regex''' + + # Array of strings used for metadata and reporting purposes. + tags = ["tag","another tag"] + + # A regex match may have many groups, this allows you to specify the group that should be used as (which group the secret is contained in) + # its entropy checked if `entropy` is set. + secretGroup = 3 + + # Float representing the minimum shannon entropy a regex group must have to be considered a secret. + # Shannon entropy measures how random a data is. Since secrets are usually composed of many random characters, they typically have high entropy + entropy = 3.5 + + # Keywords are used for pre-regex check filtering. + # If rule has keywords but the text fragment being scanned doesn't have at least one of it's keywords, it will be skipped for processing further. + # Ideally these values should either be part of the identifier or unique strings specific to the rule's regex + # (introduced in v8.6.0) + keywords = [ + "auth", + "password", + "token", + ] + + # You can include an allowlist table for a single rule to reduce false positives or ignore commits + # with known/rotated secrets + [rules.allowlist] + description = "ignore commit A" + commits = [ "commit-A", "commit-B"] + paths = [ + '''go\.mod''', + '''go\.sum''' + ] + # note: (rule) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + regexes = [ + '''process''', + '''getenv''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + + + # This is a global allowlist which has a higher order of precedence than rule-specific allowlists. + # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no + # secrets will be detected for said commit. The same logic applies for regexes and paths. + [allowlist] + description = "global allow list" + commits = [ "commit-A", "commit-B", "commit-C"] + paths = [ + '''gitleaks\.toml''', + '''(.*?)(jpg|gif|doc)''' + ] + + # note: (global) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + + regexes = [ + '''219-09-9999''', + '''078-05-1120''', + '''(9[0-9]{2}|666)-\d{2}-\d{4}''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + ``` + + +![config](/images/platform/secret-scanning/secret-scanning-config.png) + +## Ignoring Known Secrets +If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. + +### infisical-scan:ignore + +To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. + +```js example.js +function helloWorld() { + console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore +} +``` + +### .infisicalignore +An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. +You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. +By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. + +```.ignore .infisicalignore +bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 +bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 +1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 +``` diff --git a/docs/documentation/setup/networking.mdx b/docs/documentation/setup/networking.mdx index 4a666b73c..6de27c3c0 100644 --- a/docs/documentation/setup/networking.mdx +++ b/docs/documentation/setup/networking.mdx @@ -4,33 +4,36 @@ sidebarTitle: "Networking" description: "Network configuration details for Infisical Cloud" --- -## Overview - When integrating your infrastructure with Infisical Cloud, you may need to configure network access controls. This page provides the IP addresses that Infisical uses to communicate with your services. -## Egress IP Addresses +## Infisical IP Addresses -Infisical Cloud operates from two regions: US and EU. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the egress IPs Infisical uses when making outbound requests to your services. +Infisical Cloud operates from multiple regions. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the IP addresses that Infisical uses when making outbound requests to your services. -### US Region + + + ``` + 3.213.63.16 + 54.164.68.7 + ``` + + + + ``` + 3.77.89.19 + 3.125.209.189 + ``` + + + + For dedicated Infisical deployments, please contact your account manager for the specific IP addresses used in your dedicated environment. + + -To allow connections from Infisical US, add these IP addresses to your ingress rules: + +These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + -- `3.213.63.16` -- `54.164.68.7` +## What These IP Addresses Are Used For -### EU Region - -To allow connections from Infisical EU, add these IP addresses to your ingress rules: - -- `3.77.89.19` -- `3.125.209.189` - -## Common Use Cases - -You may need to allow Infisical’s egress IPs if your services require inbound connections for: - -- Secret rotation - When Infisical needs to send requests to your systems to automatically rotate credentials -- Dynamic secrets - When Infisical generates and manages temporary credentials for your cloud services -- Secret integrations - When syncing secrets with third-party services like Azure Key Vault -- Native authentication with machine identities - When using methods like Kubernetes authentication +These IP addresses represent the source IPs you'll see when Infisical Cloud makes connections to your infrastructure. All outbound traffic from Infisical Cloud originates from these IP addresses, ensuring predictable source IP addresses for your firewall rules. diff --git a/docs/images/app-connections/github-radar/create-github-radar-app-method.png b/docs/images/app-connections/github-radar/create-github-radar-app-method.png new file mode 100644 index 000000000..2aa403dbc Binary files /dev/null and b/docs/images/app-connections/github-radar/create-github-radar-app-method.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-app-created.png b/docs/images/app-connections/github-radar/github-radar-app-created.png new file mode 100644 index 000000000..d4b9a9374 Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-app-created.png differ diff --git a/docs/images/app-connections/github-radar/github-radar-authorize.png b/docs/images/app-connections/github-radar/github-radar-authorize.png new file mode 100644 index 000000000..a113b761c Binary files /dev/null and b/docs/images/app-connections/github-radar/github-radar-authorize.png differ diff --git a/docs/images/app-connections/github-radar/select-github-radar-connection.png b/docs/images/app-connections/github-radar/select-github-radar-connection.png new file mode 100644 index 000000000..35a9e574d Binary files /dev/null and b/docs/images/app-connections/github-radar/select-github-radar-connection.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png new file mode 100644 index 000000000..4c55482e2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-1.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png new file mode 100644 index 000000000..50959a5c2 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-10.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png new file mode 100644 index 000000000..5b9aa6ebc Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-2.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png new file mode 100644 index 000000000..b9fae23ed Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-3.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png new file mode 100644 index 000000000..52e27cec1 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-4.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png new file mode 100644 index 000000000..aed2940ca Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-5.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png new file mode 100644 index 000000000..1ed7a8173 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-6.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png new file mode 100644 index 000000000..f7646855d Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-7.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png new file mode 100644 index 000000000..012e09796 Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-8.png differ diff --git a/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png new file mode 100644 index 000000000..d53849e6b Binary files /dev/null and b/docs/images/app-connections/github-radar/self-hosted-github-radar-step-9.png differ diff --git a/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png new file mode 100644 index 000000000..379d6e0e1 Binary files /dev/null and b/docs/images/app-connections/gitlab/create-gitlab-access-token-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png new file mode 100644 index 000000000..85da8a929 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-access-token-connection-created.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-add-access-token.png b/docs/images/app-connections/gitlab/gitlab-add-access-token.png new file mode 100644 index 000000000..b1307f774 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-add-access-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-copy-token.png b/docs/images/app-connections/gitlab/gitlab-copy-token.png new file mode 100644 index 000000000..e425ac3ef Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-copy-token.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png new file mode 100644 index 000000000..69575afe7 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-secret-scanning-token.png differ diff --git a/docs/images/app-connections/gitlab/select-gitlab-connection.png b/docs/images/app-connections/gitlab/select-gitlab-connection.png new file mode 100644 index 000000000..0f559477d Binary files /dev/null and b/docs/images/app-connections/gitlab/select-gitlab-connection.png differ diff --git a/docs/images/app-connections/mysql/create-username-and-password-method.png b/docs/images/app-connections/mysql/create-username-and-password-method.png new file mode 100644 index 000000000..0efd58241 Binary files /dev/null and b/docs/images/app-connections/mysql/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/mysql/select-mysql-connection.png b/docs/images/app-connections/mysql/select-mysql-connection.png new file mode 100644 index 000000000..8d6e6312c Binary files /dev/null and b/docs/images/app-connections/mysql/select-mysql-connection.png differ diff --git a/docs/images/app-connections/mysql/username-and-password-connection.png b/docs/images/app-connections/mysql/username-and-password-connection.png new file mode 100644 index 000000000..1d1b2fae6 Binary files /dev/null and b/docs/images/app-connections/mysql/username-and-password-connection.png differ diff --git a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png index 50c9f89bd..5ddf3920e 100644 Binary files a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png and b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png index 14d2d48b2..fd7834302 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png new file mode 100644 index 000000000..b53f52a1a Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png b/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png index d3a804f8f..e978c7d30 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-mongodb.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png index d412109fa..0ba6aa172 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png new file mode 100644 index 000000000..011dfadc7 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png differ diff --git a/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png b/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png new file mode 100644 index 000000000..a8d22f088 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/kubernetes-lease-value.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-cql-statements.png b/docs/images/platform/dynamic-secrets/modify-cql-statements.png index d1e1b9b98..6bbb44780 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-cql-statements.png and b/docs/images/platform/dynamic-secrets/modify-cql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png index c8cd662d0..d9675849d 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png and b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-redis-statement.png b/docs/images/platform/dynamic-secrets/modify-redis-statement.png index c9726f752..d37cf9bd0 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-redis-statement.png and b/docs/images/platform/dynamic-secrets/modify-redis-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png index 973fcf731..bfff5baa0 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png and b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png index 8ad9fc0e3..312a63d6c 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png and b/docs/images/platform/dynamic-secrets/modify-sql-statement-mysql.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png index 0874aa23d..800ef05b1 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png and b/docs/images/platform/dynamic-secrets/modify-sql-statement-oracle.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png b/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png index e399db47d..58c33e655 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png and b/docs/images/platform/dynamic-secrets/modify-sql-statements-mssql.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sql-statements.png index d0f3b09da..feda34830 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-sql-statements.png and b/docs/images/platform/dynamic-secrets/modify-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png index 9ac56f456..c133505b7 100644 Binary files a/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png and b/docs/images/platform/dynamic-secrets/sap-ase/dynamic-secret-sap-ase-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png b/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png index 44c41bd52..fc7e9f663 100644 Binary files a/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png and b/docs/images/platform/dynamic-secrets/snowflake/dynamic-secret-snowflake-sql-statements.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png new file mode 100644 index 000000000..0424286d9 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png new file mode 100644 index 000000000..a270cb214 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png new file mode 100644 index 000000000..6effe4574 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png new file mode 100644 index 000000000..dd97ccc4e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png differ diff --git a/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png new file mode 100644 index 000000000..30ac12545 Binary files /dev/null and b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png differ diff --git a/docs/images/platform/secret-scanning/exposed-secret.png b/docs/images/platform/secret-scanning/exposed-secret.png deleted file mode 100644 index 727765292..000000000 Binary files a/docs/images/platform/secret-scanning/exposed-secret.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-app-id.png b/docs/images/platform/secret-scanning/github-app-copy-app-id.png deleted file mode 100644 index a94cb5ece..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-app-id.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-copy-slug.png b/docs/images/platform/secret-scanning/github-app-copy-slug.png deleted file mode 100644 index c555dcd41..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-copy-slug.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-app-create-private-key.png b/docs/images/platform/secret-scanning/github-app-create-private-key.png deleted file mode 100644 index 50f602a36..000000000 Binary files a/docs/images/platform/secret-scanning/github-app-create-private-key.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-configure-app.png b/docs/images/platform/secret-scanning/github-configure-app.png deleted file mode 100644 index df64eeb18..000000000 Binary files a/docs/images/platform/secret-scanning/github-configure-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app-button.png b/docs/images/platform/secret-scanning/github-create-app-button.png deleted file mode 100644 index 3ea4b2d38..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app-button.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-create-app.png b/docs/images/platform/secret-scanning/github-create-app.png deleted file mode 100644 index f4d1cdb8c..000000000 Binary files a/docs/images/platform/secret-scanning/github-create-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-register-app.png b/docs/images/platform/secret-scanning/github-register-app.png deleted file mode 100644 index 904c07bf2..000000000 Binary files a/docs/images/platform/secret-scanning/github-register-app.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-repo-permissions.png b/docs/images/platform/secret-scanning/github-repo-permissions.png deleted file mode 100644 index 53eae9a41..000000000 Binary files a/docs/images/platform/secret-scanning/github-repo-permissions.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org-2.png b/docs/images/platform/secret-scanning/github-select-org-2.png deleted file mode 100644 index 55b945c18..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org-2.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-org.png b/docs/images/platform/secret-scanning/github-select-org.png deleted file mode 100644 index 7d6e5abc5..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-org.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-select-repos.png b/docs/images/platform/secret-scanning/github-select-repos.png deleted file mode 100644 index 51a6648d2..000000000 Binary files a/docs/images/platform/secret-scanning/github-select-repos.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github-subscribed-events.png b/docs/images/platform/secret-scanning/github-subscribed-events.png deleted file mode 100644 index 7aa6b431f..000000000 Binary files a/docs/images/platform/secret-scanning/github-subscribed-events.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-1.png b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png new file mode 100644 index 000000000..62fc84459 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-1.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-2.png b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png new file mode 100644 index 000000000..5962406e4 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-2.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-3.png b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png new file mode 100644 index 000000000..d1a0e81a0 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-3.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-4.png b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png new file mode 100644 index 000000000..f46045250 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-4.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-5.png b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png new file mode 100644 index 000000000..9f0891888 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-5.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-6.png b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png new file mode 100644 index 000000000..886c0ca45 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-6.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-7.png b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png new file mode 100644 index 000000000..bc34c5fdb Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-7.png differ diff --git a/docs/images/platform/secret-scanning/github/github-data-source-step-8.png b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png new file mode 100644 index 000000000..75b9f8175 Binary files /dev/null and b/docs/images/platform/secret-scanning/github/github-data-source-step-8.png differ diff --git a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png b/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png deleted file mode 100644 index 11f24fd74..000000000 Binary files a/docs/images/platform/secret-scanning/infisical-connect-secret-scanner.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/needs-attention.png b/docs/images/platform/secret-scanning/needs-attention.png deleted file mode 100644 index 6ac664ead..000000000 Binary files a/docs/images/platform/secret-scanning/needs-attention.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/overview.png b/docs/images/platform/secret-scanning/overview.png deleted file mode 100644 index 19981fa11..000000000 Binary files a/docs/images/platform/secret-scanning/overview.png and /dev/null differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-config.png b/docs/images/platform/secret-scanning/secret-scanning-config.png new file mode 100644 index 000000000..b844f711f Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-config.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-data-sources.png b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png new file mode 100644 index 000000000..344178f54 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-data-sources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-findings.png b/docs/images/platform/secret-scanning/secret-scanning-findings.png new file mode 100644 index 000000000..2f0dde504 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-findings.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-resources.png b/docs/images/platform/secret-scanning/secret-scanning-resources.png new file mode 100644 index 000000000..c47b252fc Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-resources.png differ diff --git a/docs/images/platform/secret-scanning/secret-scanning-scans.png b/docs/images/platform/secret-scanning/secret-scanning-scans.png new file mode 100644 index 000000000..f710dbc20 Binary files /dev/null and b/docs/images/platform/secret-scanning/secret-scanning-scans.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png new file mode 100644 index 000000000..4e797ba67 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png new file mode 100644 index 000000000..4a59fc218 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png new file mode 100644 index 000000000..582502e0d Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png new file mode 100644 index 000000000..143568bb0 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png new file mode 100644 index 000000000..c889e047a Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png new file mode 100644 index 000000000..4903118c8 Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/mysql-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png b/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png new file mode 100644 index 000000000..78cc8c61a Binary files /dev/null and b/docs/images/secret-rotations-v2/mysql-credentials/select-mysql-credentials-option.png differ diff --git a/docs/integrations/app-connections/github-radar.mdx b/docs/integrations/app-connections/github-radar.mdx new file mode 100644 index 000000000..376973efd --- /dev/null +++ b/docs/integrations/app-connections/github-radar.mdx @@ -0,0 +1,121 @@ +--- +title: "GitHub Radar Connection" +description: "Learn how to configure a GitHub Radar Connection for Infisical." +--- + +Infisical supports GitHub App installation for creating a GitHub Radar Connection. + + + GitHub Radar Connections are specifically configured for [Secret Scanning](/documentation/platform/secret-scanning/overview) and require specific permissions and webhook configuration. + + Check out our [GitHub Connection](/integrations/app-connections/github) for secret management features such as [Secret Syncs](/integrations/secret-syncs/overview). + + + + Using a GitHub Radar Connection with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub App Settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + ![create github radar app](/images/app-connections/github-radar/self-hosted-github-radar-step-1.png) + + Configure the following fields: + + 1. **Name** - give your app a name + 2. **Homepage URL** - your self-hosted domain (i.e. `https://your-domain.com`) + 3. **Callback URL** - the callback URL for your domain (i.e. `https://your-domain.com/organization/app-connections/github-radar/oauth/callback`) + 4. **User Authorization** - enable request user authorization on app installation + + ![github radar app details](/images/app-connections/github-radar/self-hosted-github-radar-step-2.png) + + Enable and configure the Webhook fields: + + - **Webhook URL** - the webhook URL for your domain (i.e. `https://your-domain.com/secret-scanning/webhooks/github`) + - **Webhook Secret** - a strong, generated secret to verify webhook payloads + - **SSL Verification** - enable SSL verification + + ![github radar app webhook](/images/app-connections/github-radar/self-hosted-github-radar-step-3.png) + + Set the following repository permissions: + - **Contents**: `Read-only` + - **Metadata**: `Read-only` + + ![github radar app permissions 1](/images/app-connections/github-radar/self-hosted-github-radar-step-4.png) + ![github radar app permissions 2](/images/app-connections/github-radar/self-hosted-github-radar-step-5.png) + + Subscribe to the following events: + - **Push** + + ![github radar app events](/images/app-connections/github-radar/self-hosted-github-radar-step-6.png) + + Create the Github application. + ![github radar app complete](/images/app-connections/github-radar/self-hosted-github-radar-step-7.png) + + + Generate a new **Client Secret** for your GitHub application. + ![github radar app client secret](/images/app-connections/github-radar/self-hosted-github-radar-step-8.png) + + Generate a new **Private Key** for your Github application. + + You will need to copy the contents of the .pem file downloaded + + ![github radar app private key](/images/app-connections/github-radar/self-hosted-github-radar-step-9.png) + + Obtain the following credentials: + + 1. **Slug** - the slug of your application found in the URL + 2. **App ID** - the ID of your application + 3. **Client ID** - the client ID of your application + 4. **Client Secret** - the client secret generated above + 5. **Private Key** - the contents of the private key .pem file generated above + 6. **Webhook Secret** - the secret generated in the previous step when configuring the webhook + + ![github radar app credentials](/images/app-connections/github-radar/self-hosted-github-radar-step-10.png) + + Back in your Infisical instance, add the six new environment variables for the credentials of your GitHub Radar application: + + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_PRIVATE_KEY`: The **Private Key** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET`: The **Webhook Secret** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + +## Setup GitHub Radar Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Radar Connection** option from the connection options modal. + ![Select GitHub Radar Connection](/images/app-connections/github-radar/select-github-radar-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github-radar/create-github-radar-app-method.png) + + + You will then be redirected to the GitHub App installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github-radar/github-radar-authorize.png) + + + Your **GitHub Radar Connection** is now available for use. + ![GitHub Radar Connection](/images/app-connections/github-radar/github-radar-app-created.png) + + \ No newline at end of file diff --git a/docs/integrations/app-connections/mysql.mdx b/docs/integrations/app-connections/mysql.mdx new file mode 100644 index 000000000..38a8a4e97 --- /dev/null +++ b/docs/integrations/app-connections/mysql.mdx @@ -0,0 +1,129 @@ +--- +title: "MySQL Connection" +description: "Learn how to configure a MySQL Connection for Infisical." +--- + +Infisical supports connecting to MySQL using a database role. + +## Configure a MySQL Role for Infisical + + + + Infisical recommends creating a designated role in your MySQL database for your connection. + ```SQL + -- create user role + CREATE USER 'infisical_role'@'%' IDENTIFIED BY 'my-password'; + ``` + + + Depending on how you intend to use your MySQL connection, you'll need to grant one or more of the following permissions. + + To learn more about MySQL's permission system, please visit their [documentation](https://dev.mysql.com/doc/refman/8.4/en/grant.html). + + + + For Secret Rotations, your Infisical user will require the ability to alter other users' passwords: + ```SQL + -- enable permissions to alter login credentials + GRANT CREATE USER ON *.* TO 'infisical_role'@'%'; + + -- Apply changes + FLUSH PRIVILEGES; + ``` + + + + + You'll need the following information to create your MySQL connection: + - `host` - The hostname or IP address of your MySQL server + - `port` - The port number your MySQL server is listening on (default: 3306) + - `database` - The name of the specific database you want to connect to + - `username` - The role name of the login created in the steps above + - `password` - The role password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + If you are self-hosting Infisical and intend to connect to an internal/private IP address, be sure to set the `ALLOW_INTERNAL_IP_CONNECTIONS` environment variable to `true`. + + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **MySQL Connection** option. + ![Select MySQL Connection](/images/app-connections/mysql/select-mysql-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to MySQL**. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ![Create MySQL Connection](/images/app-connections/mysql/create-username-and-password-method.png) + + 4. Your **MySQL Connection** is now available for use. + ![Assume Role MySQL Connection](/images/app-connections/mysql/username-and-password-connection.png) + + + To create a MySQL Connection, make an API request to the [Create MySQL Connection](/api-reference/endpoints/app-connections/mysql/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/mysql \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mysql-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 3306, + "database": "default", + "username": "infisical_role", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mysql-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "mysql", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 3306, + "database": "default", + "username": "infisical_role", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/teamcity.mdx b/docs/integrations/app-connections/teamcity.mdx index 1ffafe637..889355954 100644 --- a/docs/integrations/app-connections/teamcity.mdx +++ b/docs/integrations/app-connections/teamcity.mdx @@ -3,7 +3,7 @@ title: "TeamCity Connection" description: "Learn how to configure a TeamCity Connection for Infisical." --- -Infisical supports connecting to TeamCity using an Access Token to securely sync your secrets to TeamCity. +Infisical supports connecting to TeamCity using Access Tokens. ## Setup TeamCity Connection in Infisical diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx index 8ef4a5647..7ab7bea1b 100644 --- a/docs/integrations/app-connections/vercel.mdx +++ b/docs/integrations/app-connections/vercel.mdx @@ -3,7 +3,7 @@ title: "Vercel Connection" description: "Learn how to configure a Vercel Connection for Infisical." --- -Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. +Infisical supports connecting to Vercel using API Tokens. ## Setup Vercel Connection in Infisical diff --git a/docs/integrations/app-connections/windmill.mdx b/docs/integrations/app-connections/windmill.mdx index ca4aa7da4..5cab9fa38 100644 --- a/docs/integrations/app-connections/windmill.mdx +++ b/docs/integrations/app-connections/windmill.mdx @@ -3,7 +3,7 @@ title: "Windmill Connection" description: "Learn how to configure a Windmill Connection for Infisical." --- -Infisical supports connecting to Windmill using an **Access Token** to securely sync your secrets to Windmill. +Infisical supports connecting to Windmill using Access Tokens. ## Get a Windmill Access Token diff --git a/docs/integrations/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx index a33f54c8d..6e2b96b4a 100644 --- a/docs/integrations/secret-syncs/1password.mdx +++ b/docs/integrations/secret-syncs/1password.mdx @@ -46,7 +46,7 @@ description: "Learn how to configure a 1Password Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over 1Password when keys conflict. - **Import Secrets (Prioritize 1Password)**: Imports secrets from the destination endpoint before syncing, prioritizing values from 1Password over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 11f0c94ad..abc52d971 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -40,7 +40,7 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Parameter Store when keys conflict. - **Import Secrets (Prioritize AWS Parameter Store)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Parameter Store over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/aws-secrets-manager.mdx b/docs/integrations/secret-syncs/aws-secrets-manager.mdx index f7654eeae..91c606b0a 100644 --- a/docs/integrations/secret-syncs/aws-secrets-manager.mdx +++ b/docs/integrations/secret-syncs/aws-secrets-manager.mdx @@ -43,7 +43,7 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize AWS Secrets Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/azure-app-configuration.mdx b/docs/integrations/secret-syncs/azure-app-configuration.mdx index ee47504bc..f4aaa7edd 100644 --- a/docs/integrations/secret-syncs/azure-app-configuration.mdx +++ b/docs/integrations/secret-syncs/azure-app-configuration.mdx @@ -48,7 +48,7 @@ description: "Learn how to configure an Azure App Configuration Sync for Infisic - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure App Configuration)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/azure-key-vault.mdx b/docs/integrations/secret-syncs/azure-key-vault.mdx index 609ba8b8d..d19a0162e 100644 --- a/docs/integrations/secret-syncs/azure-key-vault.mdx +++ b/docs/integrations/secret-syncs/azure-key-vault.mdx @@ -51,7 +51,7 @@ description: "Learn how to configure a Azure Key Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure Key Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/camunda.mdx b/docs/integrations/secret-syncs/camunda.mdx index df57a5b7d..0e977aa27 100644 --- a/docs/integrations/secret-syncs/camunda.mdx +++ b/docs/integrations/secret-syncs/camunda.mdx @@ -39,7 +39,7 @@ description: "Learn how to configure a Camunda Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Camunda when keys conflict. - **Import Secrets (Prioritize Camunda)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Camunda over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/databricks.mdx b/docs/integrations/secret-syncs/databricks.mdx index 225bad5b1..e11537420 100644 --- a/docs/integrations/secret-syncs/databricks.mdx +++ b/docs/integrations/secret-syncs/databricks.mdx @@ -46,7 +46,7 @@ description: "Learn how to configure a Databricks Sync for Infisical." Databricks does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index ace63787d..df490c6b5 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -42,7 +42,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over GCP Secret Manager when keys conflict. - **Import Secrets (Prioritize GCP Secret Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from GCP Secret Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/github.mdx b/docs/integrations/secret-syncs/github.mdx index 7786567cc..14b2d9a7f 100644 --- a/docs/integrations/secret-syncs/github.mdx +++ b/docs/integrations/secret-syncs/github.mdx @@ -62,7 +62,7 @@ description: "Learn how to configure a GitHub Sync for Infisical." GitHub does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/hashicorp-vault.mdx b/docs/integrations/secret-syncs/hashicorp-vault.mdx index 48e4d8dfd..fae2e0962 100644 --- a/docs/integrations/secret-syncs/hashicorp-vault.mdx +++ b/docs/integrations/secret-syncs/hashicorp-vault.mdx @@ -54,7 +54,7 @@ description: "Learn how to configure a Hashicorp Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Hashicorp Vault when keys conflict. - **Import Secrets (Prioritize Hashicorp Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Hashicorp Vault over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/humanitec.mdx b/docs/integrations/secret-syncs/humanitec.mdx index ec36bd4da..f252724fb 100644 --- a/docs/integrations/secret-syncs/humanitec.mdx +++ b/docs/integrations/secret-syncs/humanitec.mdx @@ -55,7 +55,7 @@ description: "Learn how to configure a Humanitec Sync for Infisical." Humanitec does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx index 00b7120e7..396b4d13f 100644 --- a/docs/integrations/secret-syncs/oci-vault.mdx +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -57,7 +57,7 @@ description: "Learn how to configure an Oracle Cloud Infrastructure Vault Sync f - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over OCI Vault when keys conflict. - **Import Secrets (Prioritize OCI Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from OCI Vault over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index 87527fd02..937c8d826 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -99,6 +99,12 @@ via the UI or API for the third-party service you intend to sync secrets to. Key Schemas transform your secret keys by applying a prefix, suffix, or format pattern during sync to external destinations. This makes it clear which secrets are managed by Infisical and prevents accidental changes to unrelated secrets. +Any destination secrets which do not match the schema will not get deleted or updated by Infisical. + +Key Schemas use handlebars syntax to define dynamic values. Here's a full list of available variables: +- `{{secretKey}}` - The key of the secret +- `{{environment}}` - The environment which the secret is in (e.g. dev, staging, prod) + **Example:** - Infisical key: `SECRET_1` - Schema: `INFISICAL_{{secretKey}}` diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx index 3482101ca..52f2c1bac 100644 --- a/docs/integrations/secret-syncs/teamcity.mdx +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -48,7 +48,7 @@ description: "Learn how to configure a TeamCity Sync for Infisical." Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/terraform-cloud.mdx b/docs/integrations/secret-syncs/terraform-cloud.mdx index d2f762ef1..c48b87609 100644 --- a/docs/integrations/secret-syncs/terraform-cloud.mdx +++ b/docs/integrations/secret-syncs/terraform-cloud.mdx @@ -56,7 +56,7 @@ description: "Learn how to configure a Terraform Cloud Sync for Infisical." Terraform Cloud does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx index c903d3faa..74cffbc11 100644 --- a/docs/integrations/secret-syncs/vercel.mdx +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -43,7 +43,7 @@ description: "Learn how to configure a Vercel Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/windmill.mdx b/docs/integrations/secret-syncs/windmill.mdx index e98a2c7b6..c0757ef37 100644 --- a/docs/integrations/secret-syncs/windmill.mdx +++ b/docs/integrations/secret-syncs/windmill.mdx @@ -44,7 +44,7 @@ description: "Learn how to configure a Windmill Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Windmill when keys conflict. - **Import Secrets (Prioritize Windmill)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Windmill over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/internals/bug-bounty.mdx b/docs/internals/bug-bounty.mdx deleted file mode 100644 index 2dc4cd662..000000000 --- a/docs/internals/bug-bounty.mdx +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: "Bug bounty program" -description: " Learn about our bug bounty program and how to report vulnerabilities." ---- - -The Infisical Bug Bounty Program is our way of recognizing and rewarding the work of security researchers who help keep our platform secure. By reporting vulnerabilities or potential risks, you help us protect secrets, infrastructure, and the organizations who rely on us. - -We value reports that help identify vulnerabilities that affect the integrity of secrets, prevent unauthorized access to environments, or expose flaws in our authentication or authorization flows. - -### How to Report - -- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept. -- We will acknowledge receipt within 3 business days for reports that are clearly written, technically sound, and plausibly within scope. -- We'll provide an initial assessment or next steps within 5 business days. -- **Please note**: We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope. - - -### What's in Scope? - -- Vulnerabilities in our cloud-hosted platform (e.g., `app.infisical.com`, `eu.infisical.com`) -- Security issues in the open source Infisical codebase, as maintained in our official GitHub repository -- Authentication bypass, privilege escalation, or access to secrets/data without authorization - -### Reward Guidelines - -Bounties are based on severity, impact, and exploitability, as well as whether the report introduces a new vulnerability class or helps improve an existing fix. - -| Severity | Examples | Typical Reward (USD currency) | -| --- | --- | --- | -| **Critical** | Full unauthorized access to secrets, authentication bypass, cross-tenant access, RCE, full compromise, etc | $2,000 - $5,000 | -| **High** | Privilege escalation, project-level access without authorization, persistent DoS | $750 - $2,000 | -| **Medium** | Info disclosure, scoped DoS (e.g. ReDoS with auth), or minor access control issues | $250 - $1,000 | -| **Low / Informational** | Missing headers, CSP warnings, theoretical flaws, self-hosting misconfigurations | Recognition only | - - -We may award lower amounts for: -- Duplicate class vulnerabilities already under review -- Patch bypasses of previously rewarded issues -- Vulnerabilities requiring unrealistic attacker conditions - -All final reward amounts are determined at Infisical's discretion based on impact, report quality, and how actionable the issue is. - - -### Out of Scope - -- Social engineering or phishing (including email hyperlink injection without code execution) -- Rate limiting issues on non-sensitive endpoints -- Denial-of-service attacks that require authentication and don't impact core service availability -- Findings based on outdated or forked code not maintained by the Infisical team -- Vulnerabilities in third-party dependencies unless they result in a direct risk to Infisical users - - -### Responsible Disclosure - -We ask that researchers: - -- Avoid accessing data that isn't yours -- Do not publicly disclose without coordination -- Use testing accounts where possible -- Give us a reasonable window to investigate and patch before going public - -Researchers can also spin up our [self-hosted version of Infisical](/self-hosting/overview) to test for vulnerabilities locally. - -### Program Conduct and Enforcement - -We value professional and collaborative interaction with security researchers. To maintain the integrity of our bug bounty program, we expect all participants to adhere to the following guidelines: - -- Maintain professional communication in all interactions -- Do not threaten public disclosure of vulnerabilities before we've had reasonable time to investigate and address the issue -- Do not attempt to extort or coerce compensation through threats -- Follow the responsible disclosure process outlined in this document -- Do not use automated scanning tools without prior permission - -Violations of these guidelines may result in: - -1. **Warning**: For minor violations, we may issue a warning explaining the violation and requesting compliance with program guidelines. -2. **Temporary Ban**: Repeated minor violations or more serious violations may result in a temporary suspension from the program. -3. **Permanent Ban**: Severe violations such as threats, extortion attempts, or unauthorized public disclosure will result in permanent removal from the Infisical Bug Bounty Program. - -We reserve the right to reject reports, withhold bounties, and remove participants from the program at our discretion for conduct that undermines the collaborative spirit of security research. - -Infisical is committed to working respectfully with security researchers who follow these guidelines, and we strive to recognize and reward valuable contributions that help protect our platform and users. diff --git a/docs/internals/permissions/organization-permissions.mdx b/docs/internals/permissions/organization-permissions.mdx index 6de3bd6fe..80c843851 100644 --- a/docs/internals/permissions/organization-permissions.mdx +++ b/docs/internals/permissions/organization-permissions.mdx @@ -142,12 +142,10 @@ Below is a comprehensive list of all available organization-level subjects and t #### Subject: `billing` -| Action | Description | -| -------- | ------------------------------------------------ | -| `read` | View billing information and subscription status | -| `create` | Set up new payment methods or subscriptions | -| `edit` | Modify billing details or subscription plans | -| `delete` | Remove payment methods or cancel subscriptions | +| Action | Description | +| ---------------- | ------------------------------------------------ | +| `read` | View billing information and subscription status | +| `manage-billing` | Manage billing details and subscription plans | ### Templates & Automation diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 96604f717..da9351188 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -323,3 +323,32 @@ Supports conditions and permission inversion | `create` | Create new SSH certificate templates | | `edit` | Modify SSH template configurations | | `delete` | Remove SSH certificate templates | + +### Secret Scanning + +#### Subject: `secret-scanning-data-sources` + +| Action | Description | +| -------- | ---------------------------------------------------- | +| `read-data-sources` | View Data Sources | +| `create-data-sources` | Create new Data Sources | +| `edit-data-sources` | Modify Data Sources | +| `delete-data-sources` | Remove Data Sources | +| `read-data-source-resources` | View Data Source Resources | +| `read-data-source-scans` | View Data Source Scans | +| `trigger-data-source-scans` | Trigger Data Source Secret Scans | + +#### Subject: `secret-scanning-findings` + +| Action | Description | +| -------- | --------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | + + +#### Subject: `secret-scanning-configs` + +| Action | Description | +| ---------------- | ------------------------------------------------ | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 219c32287..85138be9c 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -117,7 +117,3 @@ Whether or not Infisical or your employees can access data in the Infisical inst It should be noted that, even on Infisical Cloud, it is physically impossible for employees of Infisical to view the values of secrets if users have not explicitly granted Infisical access to their project (i.e. opted out of zero-knowledge). Please email security@infisical.com if you have any specific inquiries about employee data and security policies. - -## Bug Bounty Program -We run a [Bug Bounty Program](/internals/bug-bounty) to recognize and reward security researchers who help make Infisical more secure. -If you've found a vulnerability, please review the program details for scope, disclosure guidelines, and reward tiers. \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index f8958c941..64b827f55 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -116,9 +116,15 @@ "documentation/platform/pki/subscribers", "documentation/platform/pki/certificates", "documentation/platform/pki/acme-ca", - "documentation/platform/pki/pki-issuer", "documentation/platform/pki/est", - "documentation/platform/pki/alerting" + "documentation/platform/pki/alerting", + { + "group": "Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh" + ] + } ] }, { @@ -193,6 +199,7 @@ "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", + "documentation/platform/secret-rotation/mysql-credentials", "documentation/platform/secret-rotation/postgres-credentials" ] }, @@ -217,14 +224,17 @@ "documentation/platform/dynamic-secrets/sap-ase", "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", - "documentation/platform/dynamic-secrets/totp" + "documentation/platform/dynamic-secrets/totp", + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" ] }, { "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security" + "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/networking" ] }, "documentation/platform/project-templates", @@ -244,7 +254,13 @@ ] }, "documentation/platform/secret-sharing", - "documentation/platform/secret-scanning" + { + "group": "Secret Scanning", + "pages": [ + "documentation/platform/secret-scanning/overview", + "documentation/platform/secret-scanning/github" + ] + } ] }, { @@ -328,7 +344,8 @@ "documentation/platform/identities/oidc-auth/github", "documentation/platform/identities/oidc-auth/circleci", "documentation/platform/identities/oidc-auth/gitlab", - "documentation/platform/identities/oidc-auth/terraform-cloud" + "documentation/platform/identities/oidc-auth/terraform-cloud", + "documentation/platform/identities/oidc-auth/spire" ] }, @@ -482,10 +499,12 @@ "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", + "integrations/app-connections/github-radar", "integrations/app-connections/hashicorp-vault", "integrations/app-connections/humanitec", "integrations/app-connections/ldap", "integrations/app-connections/mssql", + "integrations/app-connections/mysql", "integrations/app-connections/oci", "integrations/app-connections/postgres", "integrations/app-connections/teamcity", @@ -998,6 +1017,19 @@ "api-reference/endpoints/secret-rotations/mssql-credentials/update" ] }, + { + "group": "MySQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mysql-credentials/create", + "api-reference/endpoints/secret-rotations/mysql-credentials/delete", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/list", + "api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mysql-credentials/update" + ] + }, { "group": "PostgreSQL Credentials", "pages": [ @@ -1013,6 +1045,47 @@ } ] }, + { + "group": "Secret Scanning", + "pages": [ + { + "group": "Data Sources", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/list", + "api-reference/endpoints/secret-scanning/data-sources/options", + { + "group": "GitHub", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/github/list", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/github/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/github/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/github/create", + "api-reference/endpoints/secret-scanning/data-sources/github/update", + "api-reference/endpoints/secret-scanning/data-sources/github/delete", + "api-reference/endpoints/secret-scanning/data-sources/github/scan", + "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" + ] + } + ] + }, + { + "group": "Findings", + "pages": [ + "api-reference/endpoints/secret-scanning/findings/list", + "api-reference/endpoints/secret-scanning/findings/update" + ] + }, + { + "group": "Configuration", + "pages": [ + "api-reference/endpoints/secret-scanning/config/get-by-project-id", + "api-reference/endpoints/secret-scanning/config/update" + ] + } + ] + }, { "group": "Identity Specific Privilege", "pages": [ @@ -1165,6 +1238,18 @@ "api-reference/endpoints/app-connections/github/delete" ] }, + { + "group": "GitHub Radar", + "pages": [ + "api-reference/endpoints/app-connections/github-radar/list", + "api-reference/endpoints/app-connections/github-radar/available", + "api-reference/endpoints/app-connections/github-radar/get-by-id", + "api-reference/endpoints/app-connections/github-radar/get-by-name", + "api-reference/endpoints/app-connections/github-radar/create", + "api-reference/endpoints/app-connections/github-radar/update", + "api-reference/endpoints/app-connections/github-radar/delete" + ] + }, { "group": "Hashicorp Vault", "pages": [ @@ -1213,6 +1298,18 @@ "api-reference/endpoints/app-connections/mssql/delete" ] }, + { + "group": "MySQL", + "pages": [ + "api-reference/endpoints/app-connections/mysql/list", + "api-reference/endpoints/app-connections/mysql/available", + "api-reference/endpoints/app-connections/mysql/get-by-id", + "api-reference/endpoints/app-connections/mysql/get-by-name", + "api-reference/endpoints/app-connections/mysql/create", + "api-reference/endpoints/app-connections/mysql/update", + "api-reference/endpoints/app-connections/mysql/delete" + ] + }, { "group": "OCI", "pages": [ @@ -1748,7 +1845,6 @@ }, "internals/components", "internals/security", - "internals/bug-bounty", "internals/service-tokens" ] }, diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index cb374071f..efce4d912 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -553,6 +553,32 @@ You can configure third-party app connections for re-use across Infisical Projec + + + The ID of the GitHub Radar App + + + + The slug of the GitHub Radar App + + + + The client ID for the GitHub Radar App + + + + The client secret for the GitHub Radar App + + + + The private key for the GitHub Radar App + + + + The webhook secret configured for payload verification in the GitHub Radar App + + + The OAuth2 client ID for GitHub OAuth Connection diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 121dcd094..d435cf0d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "@hookform/resolvers": "^3.9.1", "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", + "@lottiefiles/dotlottie-web": "^0.38.2", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.2", diff --git a/frontend/package.json b/frontend/package.json index 7cd636343..9a2cc2ba4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@hookform/resolvers": "^3.9.1", "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", + "@lottiefiles/dotlottie-web": "^0.38.2", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", "@radix-ui/react-accordion": "^1.2.2", diff --git a/frontend/public/lotties/blocks.json b/frontend/public/lotties/blocks.json new file mode 100644 index 000000000..93a6ad0cb --- /dev/null +++ b/frontend/public/lotties/blocks.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-40-add-card","ddd":0,"assets":[{"id":"comp_1","nm":"hover-add-card","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-90,"ix":10},"p":{"a":0,"k":[354.165,145.831,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.6,"y":0},"t":1,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":16,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":18,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":18,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.2],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"t":35,"s":[90]}],"ix":10},"p":{"a":0,"k":[354.171,354.168,0],"ix":2,"l":2},"a":{"a":0,"k":[354.171,354.168,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[43.168,0],[-43.168,0]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[67.709,0],[-67.709,0]],"c":false}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]},{"i":{"x":0.4,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-43.168],[0,43.168]],"c":false}]},{"t":20,"s":[{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0,-67.709],[0,67.709]],"c":false}]}],"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[354.171,354.168],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":8,"s":[145.831,145.831,0],"to":[34.722,0,0],"ti":[-34.722,0,0]},{"t":42,"s":[354.165,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.506],[0,0],[-11.506,0],[0,0],[0,11.505],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.505],[0,0],[11.506,0],[0,0],[0,-11.506]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0,"y":1},"o":{"x":0.333,"y":0},"t":15,"s":[145.831,354.17,0],"to":[0,-34.723,0],"ti":[0,34.723,0]},{"t":49,"s":[145.831,145.831,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[145.831,354.17,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":23,"s":[{"i":[[11.506,0],[0,0],[0,0.034],[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,-0.034],[0,0],[11.506,0],[0,0],[0,0.034]],"v":[[46.581,67.83],[-47.169,67.83],[-68.003,67.769],[-67.709,67.77],[-46.875,67.709],[46.875,67.709],[67.709,67.77],[67.415,67.769]],"c":true}]},{"i":{"x":0,"y":1},"o":{"x":0.167,"y":0.167},"t":25,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.581,26.291],[-47.169,26.291],[-68.003,47.125],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.415,47.125]],"c":true}]},{"t":57,"s":[{"i":[[11.506,0],[0,0],[0,-11.505],[0,0],[-11.506,0],[0,0],[0,11.506],[0,0]],"o":[[0,0],[-11.506,0],[0,0],[0,11.506],[0,0],[11.506,0],[0,0],[0,-11.505]],"v":[[46.875,-67.709],[-46.875,-67.709],[-67.709,-46.875],[-67.709,46.875],[-46.875,67.709],[46.875,67.709],[67.709,46.875],[67.709,-46.875]],"c":true}]}],"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":23,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":300,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250.002,250.002,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41],[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41]],"o":[[0,0],[0,0],[0,-0.41],[-0.41,0],[0,0],[0,0],[-0.41,0],[0,0.41],[0,0],[0,0],[0,0.41],[0.41,0],[0,0],[0,0],[0.41,0],[0,-0.41]],"v":[[3.25,-0.75],[0.75,-0.75],[0.75,-3.25],[0,-4],[-0.75,-3.25],[-0.75,-0.75],[-3.25,-0.75],[-4,0],[-3.25,0.75],[-0.75,0.75],[-0.75,3.25],[0,4],[0.75,3.25],[0.75,0.75],[3.25,0.75],[4,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.96,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[255,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,245],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.14,0],[0,0],[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14]],"o":[[0,0.14],[0,0],[-0.14,0],[0,0],[0,-0.14],[0,0],[0.14,0],[0,0]],"v":[[2.5,2.25],[2.25,2.5],[-2.25,2.5],[-2.5,2.25],[-2.5,-2.25],[-2.25,-2.5],[2.25,-2.5],[2.5,-2.25]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.96,0],[0,0],[0,-0.96],[0,0],[-0.97,0],[0,0],[0,0.96],[0,0]],"o":[[0,0],[-0.97,0],[0,0],[0,0.96],[0,0],[0.96,0],[0,0],[0,-0.96]],"v":[[2.25,-4],[-2.25,-4],[-4,-2.25],[-4,2.25],[-2.25,4],[2.25,4],[4,2.25],[4,-2.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.914,0.91,0.91,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-40-add-card').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[245,255],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":3,"cix":2,"bm":0,"ix":4,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.914,0.91,0.91],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-add-card","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-add-card","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/public/lotties/pki-template.json b/frontend/public/lotties/pki-template.json new file mode 100644 index 000000000..0001f3d07 --- /dev/null +++ b/frontend/public/lotties/pki-template.json @@ -0,0 +1,3098 @@ +{ + "v": "5.7.5", + "fr": 100, + "ip": 0, + "op": 250, + "w": 512, + "h": 532, + "nm": "Comp 1", + "ddd": 0, + "metadata": {}, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 12345679, + "ty": 4, + "nm": "Group Layer 8", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [373.76, 495.53049180327866, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [52.459016393442624, 52.459016393442624, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [220.741, 37.184], + [225.501, 35.896], + [228.749, 32.36800000000001], + [229.981, 27.216000000000008], + [228.749, 22.12], + [225.501, 18.592], + [220.741, 17.304], + [215.981, 18.592], + [212.677, 22.12], + [211.501, 27.216000000000008], + [212.677, 32.36800000000001], + [215.981, 35.896], + [220.741, 37.184], + [220.741, 37.184], + [220.741, 37.184] + ], + "i": [ + [0, 0], + [-1.380999999999972, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.8220000000000027, 1.493299999999991], + [1.382000000000062, 0.8586999999999989], + [1.79200000000003, 0], + [1.418999999999983, -0.8586999999999989], + [0.8220000000000027, -1.493300000000005], + [0, -1.904000000000011], + [-0.7839999999999918, -1.5307000000000102], + [-1.380999999999972, -0.8586999999999989], + [-1.754000000000019, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.79200000000003, 0], + [1.382000000000062, -0.8586999999999989], + [0.8220000000000027, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7839999999999918, -1.493300000000005], + [-1.380999999999972, -0.8586999999999989], + [-1.754000000000019, 0], + [-1.380999999999972, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.8220000000000027, 1.493299999999991], + [1.418999999999983, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [221.357, 43.06400000000001], + [214.917, 41.608], + [210.49300000000005, 37.408], + [211.221, 36.232], + [211.221, 42.392], + [205.173, 42.392], + [205.173, 0], + [211.501, 0], + [211.501, 18.36800000000001], + [210.49300000000005, 16.912000000000006], + [214.973, 12.88], + [221.357, 11.424000000000007], + [229.085, 13.49600000000001], + [234.51700000000005, 19.152], + [236.533, 27.216000000000008], + [234.51700000000005, 35.28], + [229.141, 40.992], + [221.357, 43.06400000000001], + [221.357, 43.06400000000001], + [221.357, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.942000000000007, 0.9706999999999937], + [1.045999999999935, 1.829300000000003], + [-0.2426666666666506, 0.3919999999999959], + [0, -2.053333333333327], + [2.015999999999963, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -6.122666666666674], + [0.3360000000000127, 0.4853333333333296], + [-1.865999999999985, 0.9707000000000079], + [-2.38900000000001, 0], + [-2.277000000000044, -1.3813000000000102], + [-1.30600000000004, -2.389300000000006], + [0, -2.986699999999999], + [1.343999999999937, -2.389300000000006], + [2.27800000000002, -1.4187000000000012], + [2.912000000000035, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.351999999999975, 0], + [-1.903999999999996, -0.9707000000000079], + [0.2426666666666506, -0.3919999999999959], + [0, 2.053333333333327], + [-2.015999999999963, 0], + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 6.122666666666667], + [-0.3360000000000127, -0.4853333333333296], + [1.120000000000005, -1.717300000000009], + [1.867000000000075, -0.9706999999999937], + [2.875, 0], + [2.314999999999941, 1.381299999999996], + [1.343999999999937, 2.389300000000006], + [0, 2.986699999999999], + [-1.30600000000004, 2.389300000000006], + [-2.27699999999993, 1.381299999999996], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [181.87, 43.06400000000001], + [176.438, 42], + [172.854, 38.976], + [171.566, 34.384], + [172.63, 29.960000000000008], + [176.046, 26.656000000000006], + [181.814, 24.752], + [192.342, 23.016000000000005], + [192.342, 28], + [183.046, 29.624], + [179.35, 31.248], + [178.174, 34.16], + [179.462, 37.016000000000005], + [182.878, 38.08], + [187.35799999999995, 36.96000000000001], + [190.38199999999995, 33.992], + [191.446, 29.792], + [191.446, 22.008], + [189.766, 18.36800000000001], + [185.398, 16.912000000000006], + [180.974, 18.256], + [178.23, 21.616], + [172.966, 18.98400000000001], + [175.71, 15.064000000000007], + [180.134, 12.376], + [185.566, 11.424000000000007], + [191.894, 12.768], + [196.206, 16.52], + [197.774, 22.008], + [197.774, 42.392], + [191.726, 42.392], + [191.726, 36.904], + [193.014, 37.072], + [190.27, 40.264], + [186.518, 42.336], + [181.87, 43.06400000000001], + [181.87, 43.06400000000001], + [181.87, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.567999999999984, 0.7092999999999989], + [0.8589999999999236, 1.2693000000000012], + [0, 1.7547], + [-0.7089999999999463, 1.306699999999992], + [-1.530000000000086, 0.8960000000000008], + [-2.313999999999965, 0.3733000000000004], + [-3.509333333333302, 0.5786666666666633], + [0, -1.661333333333332], + [3.098666666666645, -0.541333333333327], + [0.7839999999999918, -0.784000000000006], + [0, -1.194699999999997], + [-0.8579999999999472, -0.7467000000000041], + [-1.381000000000085, 0], + [-1.268999999999892, 0.7466999999999899], + [-0.7089999999999463, 1.2319999999999993], + [0, 1.530699999999996], + [0, 2.594666666666669], + [1.120000000000005, 0.9332999999999885], + [1.829999999999927, 0], + [1.269999999999982, -0.8960000000000008], + [0.5979999999999563, -1.381299999999996], + [1.754666666666708, 0.8773333333333255], + [-1.269000000000005, 1.11999999999999], + [-1.680000000000064, 0.6346999999999952], + [-1.903999999999996, 0], + [-1.828999999999951, -0.8960000000000008], + [-1.008000000000038, -1.6053], + [0, -2.090699999999998], + [0, -6.794666666666672], + [2.015999999999963, 0], + [0, 1.829333333333338], + [-0.4293333333333749, -0.05599999999999739], + [1.120000000000005, -0.8959999999999866], + [1.418999999999983, -0.4852999999999952], + [1.717999999999961, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.052999999999997, 0], + [-1.529999999999973, -0.7467000000000041], + [-0.8580000000000609, -1.306699999999992], + [0, -1.642700000000005], + [0.7469999999999573, -1.306700000000006], + [1.530999999999949, -0.8960000000000008], + [3.509333333333302, -0.5786666666666633], + [0, 1.661333333333332], + [-3.098666666666645, 0.541333333333327], + [-1.680000000000064, 0.2987000000000108], + [-0.7839999999999918, 0.7467000000000041], + [0, 1.157300000000006], + [0.8959999999999582, 0.7092999999999989], + [1.717999999999961, 0], + [1.307000000000016, -0.7467000000000041], + [0.7100000000000364, -1.2693000000000012], + [0, -2.594666666666669], + [0, -1.493299999999991], + [-1.081999999999994, -0.9707000000000079], + [-1.680000000000064, 0], + [-1.232000000000085, 0.8586999999999989], + [-1.754666666666708, -0.8773333333333255], + [0.5599999999999454, -1.493300000000005], + [1.269999999999982, -1.157300000000006], + [1.717999999999961, -0.6347000000000094], + [2.389999999999986, 0], + [1.866999999999962, 0.8960000000000008], + [1.045999999999935, 1.568000000000012], + [0, 6.794666666666672], + [-2.015999999999963, 0], + [0, -1.829333333333338], + [0.4293333333333749, 0.05599999999999739], + [-0.70900000000006, 1.2319999999999993], + [-1.081999999999994, 0.8960000000000008], + [-1.380999999999972, 0.4853000000000094], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [159.072, 42.392], + [159.072, 0], + [165.4, 0], + [165.4, 42.392], + [159.072, 42.392], + [159.072, 42.392], + [159.072, 42.392] + ], + "i": [ + [0, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [138.952, 43.06400000000001], + [130.888, 40.992], + [125.456, 35.28], + [123.496, 27.16], + [125.456, 19.040000000000006], + [130.832, 13.49600000000001], + [138.448, 11.424000000000007], + [144.552, 12.600000000000009], + [149.088, 15.848], + [151.888, 20.49600000000001], + [152.896, 26.096], + [152.84, 27.608], + [152.616, 29.064000000000007], + [128.48, 29.064000000000007], + [128.48, 24.024], + [149.032, 24.024], + [146.008, 26.320000000000007], + [145.616, 21.448000000000008], + [142.816, 18.032], + [138.448, 16.744], + [133.968, 18.032], + [130.944, 21.616], + [130.104, 27.216000000000008], + [130.944, 32.592], + [134.192, 36.176], + [139.008, 37.464], + [143.65599999999995, 36.232], + [146.736, 33.040000000000006], + [151.888, 35.56], + [149.088, 39.42400000000001], + [144.60799999999995, 42.11200000000001], + [138.952, 43.06400000000001], + [138.952, 43.06400000000001], + [138.952, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.351999999999975, 1.381299999999996], + [1.307000000000016, 2.389300000000006], + [0, 2.986699999999999], + [-1.307000000000016, 2.35199999999999], + [-2.240000000000009, 1.343999999999994], + [-2.836999999999989, 0], + [-1.79200000000003, -0.784000000000006], + [-1.231999999999971, -1.381299999999996], + [-0.6349999999999909, -1.754700000000014], + [0, -1.978700000000003], + [0.03699999999992087, -0.5227000000000004], + [0.1119999999999663, -0.4480000000000075], + [8.04533333333336, 0], + [0, 1.680000000000007], + [-6.850666666666712, 0], + [1.008000000000038, -0.7653333333333308], + [0.6349999999999909, 1.4187000000000012], + [1.269000000000005, 0.8213000000000079], + [1.680000000000064, 0], + [1.307000000000016, -0.8586999999999989], + [0.70900000000006, -1.567999999999998], + [-0.1490000000000009, -2.202700000000007], + [-0.7469999999999573, -1.530699999999996], + [-1.380999999999972, -0.8586999999999989], + [-1.79200000000003, 0], + [-1.268999999999892, 0.8213000000000079], + [-0.7469999999999573, 1.306699999999992], + [-1.717333333333386, -0.8400000000000034], + [1.269000000000005, -1.157300000000006], + [1.755000000000109, -0.6720000000000113], + [2.052999999999997, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.024000000000001, 0], + [-2.315000000000055, -1.4187000000000012], + [-1.307000000000016, -2.426699999999997], + [0, -3.061299999999989], + [1.343999999999937, -2.352000000000004], + [2.240000000000009, -1.3813000000000102], + [2.277000000000044, 0], + [1.79200000000003, 0.7839999999999918], + [1.232000000000085, 1.344000000000008], + [0.6720000000000255, 1.7547], + [0, 0.4852999999999952], + [-0.03700000000003456, 0.5227000000000004], + [-8.04533333333336, 0], + [0, -1.680000000000007], + [6.850666666666712, 0], + [-1.008000000000038, 0.7653333333333308], + [0.3729999999999336, -1.829300000000003], + [-0.59699999999998, -1.456000000000003], + [-1.232000000000085, -0.8586999999999989], + [-1.67999999999995, 0], + [-1.306999999999903, 0.8213000000000079], + [-0.7089999999999463, 1.530699999999996], + [-0.1870000000000118, 2.053299999999993], + [0.7839999999999918, 1.53070000000001], + [1.418999999999983, 0.8586999999999989], + [1.828999999999951, 0], + [1.307000000000016, -0.8212999999999937], + [1.717333333333386, 0.8400000000000034], + [-0.59699999999998, 1.4187000000000012], + [-1.231999999999971, 1.11999999999999], + [-1.716999999999985, 0.6346999999999952], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [111.001, 7.951999999999998], + [111.001, 0.6720000000000041], + [117.329, 0.6720000000000041], + [117.329, 7.951999999999998], + [111.001, 7.951999999999998], + [111.001, 7.951999999999998], + [111.001, 7.951999999999998] + ], + "i": [ + [0, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [111.001, 42.392], + [111.001, 12.096], + [117.329, 12.096], + [117.329, 42.392], + [111.001, 42.392], + [111.001, 42.392], + [111.001, 42.392] + ], + "i": [ + [0, 0], + [0, 10.09866666666667], + [-2.109333333333325, 0], + [0, -10.09866666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -10.09866666666666], + [2.109333333333325, 0], + [0, 10.09866666666666], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [101.41, 42.72800000000001], + [94.01800000000003, 40.040000000000006], + [91.38599999999997, 32.48], + [91.38599999999997, 17.808000000000007], + [86.06600000000003, 17.808000000000007], + [86.06600000000003, 12.096], + [86.90599999999995, 12.096], + [90.21000000000004, 10.864], + [91.38599999999997, 7.504000000000005], + [91.38599999999997, 5.152000000000001], + [97.71400000000006, 5.152000000000001], + [97.71400000000006, 12.096], + [104.602, 12.096], + [104.602, 17.808000000000007], + [97.71400000000006, 17.808000000000007], + [97.71400000000006, 32.2], + [98.21799999999996, 34.888000000000005], + [99.84199999999998, 36.568], + [102.754, 37.128], + [103.76200000000006, 37.072], + [104.826, 36.96000000000001], + [104.826, 42.392], + [103.09, 42.616], + [101.41, 42.72800000000001], + [101.41, 42.72800000000001], + [101.41, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.754999999999995, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333312, 0], + [0, 1.903999999999996], + [-0.2799999999999727, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999935, 0], + [0, -1.903999999999996], + [2.295999999999935, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7469999999999573, -0.4106999999999914], + [-1.19500000000005, 0], + [-0.3730000000000473, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6349999999999909, -0.07469999999999288], + [0.4850000000000136, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173000000000002, 0], + [-1.754999999999995, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333312, 0], + [0, -1.903999999999996], + [0.2799999999999727, 0], + [1.419000000000096, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999935, 0], + [0, 1.903999999999996], + [-2.295999999999935, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3360000000000127, 0.7092999999999989], + [0.7470000000000709, 0.3733000000000004], + [0.2989999999999782, 0], + [0.3729999999999336, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5230000000000246, 0.0747000000000071], + [-0.6349999999999909, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [78.71499999999997, 42.72800000000001], + [71.32299999999998, 40.040000000000006], + [68.69100000000003, 32.48], + [68.69100000000003, 17.808000000000007], + [63.37099999999998, 17.808000000000007], + [63.37099999999998, 12.096], + [64.21100000000001, 12.096], + [67.51499999999999, 10.864], + [68.69100000000003, 7.504000000000005], + [68.69100000000003, 5.152000000000001], + [75.019, 5.152000000000001], + [75.019, 12.096], + [81.90700000000004, 12.096], + [81.90700000000004, 17.808000000000007], + [75.019, 17.808000000000007], + [75.019, 32.2], + [75.52300000000002, 34.888000000000005], + [77.14699999999999, 36.568], + [80.05900000000003, 37.128], + [81.06700000000001, 37.072], + [82.13099999999997, 36.96000000000001], + [82.13099999999997, 42.392], + [80.39499999999998, 42.616], + [78.71499999999997, 42.72800000000001], + [78.71499999999997, 42.72800000000001], + [78.71499999999997, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.754000000000019, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333369, 0], + [0, 1.903999999999996], + [-0.2800000000000296, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999992, 0], + [0, -1.903999999999996], + [2.295999999999992, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7470000000000141, -0.4106999999999914], + [-1.19500000000005, 0], + [-0.3740000000000236, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6340000000000146, -0.07469999999999288], + [0.4850000000000136, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173999999999978, 0], + [-1.754999999999995, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333369, 0], + [0, -1.903999999999996], + [0.2800000000000296, 0], + [1.418000000000006, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999992, 0], + [0, 1.903999999999996], + [-2.295999999999992, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3359999999999559, 0.7092999999999989], + [0.7460000000000377, 0.3733000000000004], + [0.297999999999945, 0], + [0.3730000000000473, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5230000000000246, 0.0747000000000071], + [-0.6349999999999909, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [44.18799999999999, 37.184], + [48.94799999999998, 35.896], + [52.19600000000003, 32.36800000000001], + [53.428, 27.216000000000008], + [52.19600000000003, 22.12], + [48.94799999999998, 18.592], + [44.18799999999999, 17.304], + [39.428, 18.592], + [36.12400000000002, 22.12], + [34.94799999999998, 27.216000000000008], + [36.12400000000002, 32.36800000000001], + [39.428, 35.896], + [44.18799999999999, 37.184], + [44.18799999999999, 37.184], + [44.18799999999999, 37.184] + ], + "i": [ + [0, 0], + [-1.381999999999948, 0.8586999999999989], + [-0.7840000000000487, 1.493299999999991], + [0, 1.903999999999996], + [0.8209999999999695, 1.493299999999991], + [1.381000000000029, 0.8586999999999989], + [1.79200000000003, 0], + [1.418000000000006, -0.8586999999999989], + [0.8209999999999695, -1.493300000000005], + [0, -1.904000000000011], + [-0.7840000000000487, -1.5307000000000102], + [-1.382000000000005, -0.8586999999999989], + [-1.754999999999995, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.79200000000003, 0], + [1.381000000000029, -0.8586999999999989], + [0.8209999999999695, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7840000000000487, -1.493300000000005], + [-1.381999999999948, -0.8586999999999989], + [-1.754999999999995, 0], + [-1.382000000000005, 0.8586999999999989], + [-0.7840000000000487, 1.493299999999991], + [0, 1.903999999999996], + [0.8209999999999695, 1.493299999999991], + [1.418000000000006, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [44.18799999999999, 43.06400000000001], + [36.18000000000001, 40.992], + [30.46800000000002, 35.336], + [28.33999999999997, 27.216000000000008], + [30.46800000000002, 19.096], + [36.18000000000001, 13.49600000000001], + [44.18799999999999, 11.424000000000007], + [52.19600000000003, 13.49600000000001], + [57.85199999999998, 19.096], + [59.98000000000002, 27.216000000000008], + [57.85199999999998, 35.392], + [52.139999999999986, 41.048], + [44.18799999999999, 43.06400000000001], + [44.18799999999999, 43.06400000000001], + [44.18799999999999, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.425999999999988, 1.381299999999996], + [1.418000000000006, 2.389300000000006], + [0, 3.024000000000001], + [-1.41900000000004, 2.352000000000004], + [-2.389999999999986, 1.343999999999994], + [-2.949999999999989, 0], + [-2.352000000000032, -1.3813000000000102], + [-1.381999999999948, -2.389300000000006], + [0, -3.061300000000003], + [1.418000000000006, -2.389299999999992], + [2.38900000000001, -1.381299999999996], + [2.912000000000035, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.911999999999978, 0], + [-2.389999999999986, -1.381299999999996], + [-1.41900000000004, -2.389299999999992], + [0, -3.061300000000003], + [1.418000000000006, -2.389300000000006], + [2.38900000000001, -1.3813000000000102], + [2.98599999999999, 0], + [2.388999999999953, 1.343999999999994], + [1.418000000000006, 2.352000000000004], + [0, 3.061299999999989], + [-1.418999999999983, 2.389300000000006], + [-2.389999999999986, 1.343999999999994], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [0, 42.392], + [0, 0.6720000000000041], + [6.608000000000004, 0.6720000000000041], + [6.608000000000004, 36.512], + [24.639999999999986, 36.512], + [24.639999999999986, 42.392], + [0, 42.392], + [0, 42.392], + [0, 42.392] + ], + "i": [ + [0, 0], + [0, 13.90666666666666], + [-2.202666666666687, 0], + [0, -11.94666666666666], + [-6.01066666666668, 0], + [0, -1.959999999999994], + [8.21333333333331, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -13.90666666666667], + [2.202666666666687, 0], + [0, 11.94666666666667], + [6.01066666666668, 0], + [0, 1.959999999999994], + [-8.21333333333331, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [98.08047485351562, -21.67217254638672], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [246.681, 42.392], + [246.681, 0], + [253.009, 0], + [253.009, 18.032], + [252.001, 17.248], + [255.585, 12.936000000000007], + [261.297, 11.424000000000007], + [267.23299999999995, 12.88], + [271.265, 16.912000000000006], + [272.721, 22.792], + [272.721, 42.392], + [266.449, 42.392], + [266.449, 24.528000000000006], + [265.553, 20.664], + [263.201, 18.2], + [259.729, 17.304], + [256.25699999999995, 18.2], + [253.849, 20.664], + [253.009, 24.528000000000006], + [253.009, 42.392], + [246.681, 42.392], + [246.681, 42.392], + [246.681, 42.392] + ], + "i": [ + [0, 0], + [0, 14.13066666666667], + [-2.109333333333325, 0], + [0, -6.010666666666665], + [0.3360000000000127, 0.2613333333333259], + [-1.643000000000029, 0.9706999999999937], + [-2.166000000000054, 0], + [-1.717999999999961, -0.9706999999999937], + [-0.9710000000000036, -1.717300000000009], + [0, -2.202699999999993], + [0, -6.533333333333331], + [2.090666666666721, 0], + [0, 5.954666666666668], + [0.59699999999998, 1.045299999999997], + [1.007999999999925, 0.5600000000000023], + [1.305999999999926, 0], + [1.045000000000073, -0.5973000000000042], + [0.59699999999998, -1.082700000000003], + [0, -1.493300000000005], + [0, -5.954666666666668], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -14.13066666666667], + [2.109333333333325, 0], + [0, 6.010666666666665], + [-0.3360000000000127, -0.2613333333333259], + [0.7459999999999809, -1.903999999999996], + [1.641999999999967, -1.0080000000000098], + [2.240000000000009, 0], + [1.717000000000098, 0.9707000000000079], + [0.9700000000000273, 1.717299999999994], + [0, 6.533333333333331], + [-2.090666666666721, 0], + [0, -5.954666666666668], + [0, -1.5307000000000102], + [-0.5599999999999454, -1.082700000000003], + [-1.008000000000038, -0.5973000000000042], + [-1.270000000000095, 0], + [-1.007999999999953, 0.5600000000000023], + [-0.5600000000000023, 1.082700000000003], + [0, 5.954666666666668], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [237.089, 42.72800000000001], + [229.697, 40.040000000000006], + [227.065, 32.48], + [227.065, 17.808000000000007], + [221.745, 17.808000000000007], + [221.745, 12.096], + [222.585, 12.096], + [225.889, 10.864], + [227.065, 7.504000000000005], + [227.065, 5.152000000000001], + [233.393, 5.152000000000001], + [233.393, 12.096], + [240.281, 12.096], + [240.281, 17.808000000000007], + [233.393, 17.808000000000007], + [233.393, 32.2], + [233.897, 34.888000000000005], + [235.521, 36.568], + [238.433, 37.128], + [239.441, 37.072], + [240.505, 36.96000000000001], + [240.505, 42.392], + [238.769, 42.616], + [237.089, 42.72800000000001], + [237.089, 42.72800000000001], + [237.089, 42.72800000000001] + ], + "i": [ + [0, 0], + [1.755000000000052, 1.792000000000002], + [0, 3.248000000000005], + [0, 4.890666666666661], + [1.773333333333369, 0], + [0, 1.903999999999996], + [-0.2800000000000296, 0], + [-0.7839999999999918, 0.8212999999999937], + [0, 1.4187000000000012], + [0, 0.7839999999999989], + [-2.109333333333325, 0], + [0, -2.314666666666668], + [-2.295999999999992, 0], + [0, -1.903999999999996], + [2.295999999999992, 0], + [0, -4.797333333333327], + [-0.3360000000000127, -0.7467000000000041], + [-0.7459999999999809, -0.4106999999999914], + [-1.194000000000017, 0], + [-0.3729999999999905, 0.03730000000000189], + [-0.3360000000000127, 0.03729999999998768], + [0, -1.810666666666663], + [0.6350000000000477, -0.07469999999999288], + [0.48599999999999, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.173000000000002, 0], + [-1.753999999999962, -1.792000000000002], + [0, -4.890666666666661], + [-1.773333333333369, 0], + [0, -1.903999999999996], + [0.2800000000000296, 0], + [1.418999999999983, 0], + [0.7839999999999918, -0.8213000000000079], + [0, -0.7839999999999989], + [2.109333333333325, 0], + [0, 2.314666666666668], + [2.295999999999992, 0], + [0, 1.903999999999996], + [-2.295999999999992, 0], + [0, 4.797333333333327], + [0, 1.045299999999997], + [0.3359999999999559, 0.7092999999999989], + [0.7470000000000141, 0.3733000000000004], + [0.2989999999999782, 0], + [0.3740000000000236, -0.03730000000000189], + [0, 1.810666666666663], + [-0.5220000000000482, 0.0747000000000071], + [-0.6339999999999577, 0.0747000000000071], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [210.259, 7.951999999999998], + [210.259, 0.6720000000000041], + [216.587, 0.6720000000000041], + [216.587, 7.951999999999998], + [210.259, 7.951999999999998], + [210.259, 7.951999999999998], + [210.259, 7.951999999999998] + ], + "i": [ + [0, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -2.426666666666662], + [2.109333333333325, 0], + [0, 2.426666666666662], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [210.259, 42.392], + [210.259, 12.096], + [216.587, 12.096], + [216.587, 42.392], + [210.259, 42.392], + [210.259, 42.392], + [210.259, 42.392] + ], + "i": [ + [0, 0], + [0, 10.09866666666667], + [-2.109333333333325, 0], + [0, -10.09866666666667], + [2.109333333333325, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -10.09866666666666], + [2.109333333333325, 0], + [0, 10.09866666666666], + [-2.109333333333325, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [169.688, 42.392], + [159.272, 12.096], + [165.992, 12.096], + [173.944, 36.232], + [171.592, 36.232], + [179.712, 12.096], + [185.48, 12.096], + [193.544, 36.232], + [191.192, 36.232], + [199.2, 12.096], + [205.92, 12.096], + [195.448, 42.392], + [189.736, 42.392], + [181.56, 17.696], + [183.632, 17.696], + [175.456, 42.392], + [169.688, 42.392], + [169.688, 42.392], + [169.688, 42.392] + ], + "i": [ + [0, 0], + [3.47199999999998, 10.09866666666667], + [-2.240000000000009, 0], + [-2.650666666666666, -8.045333333333332], + [0.7839999999999918, 0], + [-2.706666666666649, 8.045333333333332], + [-1.922666666666657, 0], + [-2.687999999999988, -8.045333333333332], + [0.7839999999999918, 0], + [-2.669333333333327, 8.045333333333332], + [-2.240000000000009, 0], + [3.490666666666641, -10.09866666666667], + [1.903999999999996, 0], + [2.725333333333367, 8.232], + [-0.6906666666666865, 0], + [2.72533333333331, -8.232], + [1.922666666666657, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.47199999999998, -10.09866666666666], + [2.240000000000009, 0], + [2.650666666666666, 8.045333333333332], + [-0.7839999999999918, 0], + [2.706666666666649, -8.045333333333332], + [1.922666666666657, 0], + [2.687999999999988, 8.045333333333332], + [-0.7839999999999918, 0], + [2.669333333333327, -8.045333333333332], + [2.240000000000009, 0], + [-3.490666666666641, 10.09866666666666], + [-1.903999999999996, 0], + [-2.725333333333367, -8.232], + [0.6906666666666865, 0], + [-2.72533333333331, 8.232], + [-1.922666666666657, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-354.5325317382812, -77.50520324707031], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [155.86146545410156, 56.001014709472656], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [132.444, 43.06400000000001], + [124.38, 40.992], + [118.948, 35.28], + [116.988, 27.16], + [118.948, 19.040000000000006], + [124.324, 13.49600000000001], + [131.94, 11.424000000000007], + [138.044, 12.600000000000009], + [142.58, 15.848], + [145.38, 20.49600000000001], + [146.388, 26.096], + [146.332, 27.608], + [146.108, 29.064000000000007], + [121.972, 29.064000000000007], + [121.972, 24.024], + [142.524, 24.024], + [139.5, 26.320000000000007], + [139.108, 21.448000000000008], + [136.308, 18.032], + [131.94, 16.744], + [127.46, 18.032], + [124.436, 21.616], + [123.596, 27.216000000000008], + [124.436, 32.592], + [127.684, 36.176], + [132.5, 37.464], + [137.148, 36.232], + [140.228, 33.040000000000006], + [145.38, 35.56], + [142.58, 39.42400000000001], + [138.1, 42.11200000000001], + [132.444, 43.06400000000001], + [132.444, 43.06400000000001], + [132.444, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.351999999999975, 1.381299999999996], + [1.305999999999983, 2.389300000000006], + [0, 2.986699999999999], + [-1.307000000000016, 2.35199999999999], + [-2.240000000000009, 1.343999999999994], + [-2.838000000000022, 0], + [-1.79200000000003, -0.784000000000006], + [-1.232000000000028, -1.381299999999996], + [-0.6350000000000477, -1.754700000000014], + [0, -1.978700000000003], + [0.03699999999997772, -0.5227000000000004], + [0.1120000000000232, -0.4480000000000075], + [8.045333333333303, 0], + [0, 1.680000000000007], + [-6.850666666666655, 0], + [1.007999999999981, -0.7653333333333308], + [0.6340000000000146, 1.4187000000000012], + [1.269000000000005, 0.8213000000000079], + [1.67999999999995, 0], + [1.305999999999983, -0.8586999999999989], + [0.7090000000000032, -1.567999999999998], + [-0.1499999999999773, -2.202700000000007], + [-0.7470000000000141, -1.530699999999996], + [-1.382000000000005, -0.8586999999999989], + [-1.791999999999973, 0], + [-1.269999999999982, 0.8213000000000079], + [-0.7469999999999573, 1.306699999999992], + [-1.717333333333329, -0.8400000000000034], + [1.269000000000005, -1.157300000000006], + [1.754000000000019, -0.6720000000000113], + [2.052999999999997, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-3.024000000000001, 0], + [-2.314999999999998, -1.4187000000000012], + [-1.307000000000016, -2.426699999999997], + [0, -3.061299999999989], + [1.343999999999994, -2.352000000000004], + [2.240000000000009, -1.3813000000000102], + [2.276999999999987, 0], + [1.791999999999973, 0.7839999999999918], + [1.231999999999971, 1.344000000000008], + [0.6719999999999686, 1.7547], + [0, 0.4852999999999952], + [-0.03800000000001091, 0.5227000000000004], + [-8.045333333333303, 0], + [0, -1.680000000000007], + [6.850666666666655, 0], + [-1.007999999999981, 0.7653333333333308], + [0.3730000000000473, -1.829300000000003], + [-0.5979999999999563, -1.456000000000003], + [-1.232000000000028, -0.8586999999999989], + [-1.680000000000007, 0], + [-1.307000000000016, 0.8213000000000079], + [-0.7100000000000364, 1.530699999999996], + [-0.186999999999955, 2.053299999999993], + [0.7839999999999918, 1.53070000000001], + [1.418000000000006, 0.8586999999999989], + [1.829000000000008, 0], + [1.305999999999983, -0.8212999999999937], + [1.717333333333329, 0.8400000000000034], + [-0.5980000000000132, 1.4187000000000012], + [-1.232000000000028, 1.11999999999999], + [-1.718000000000018, 0.6346999999999952], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [95.32, 37.184], + [100.024, 35.896], + [103.328, 32.36800000000001], + [104.56, 27.216000000000008], + [103.328, 22.12], + [100.024, 18.592], + [95.32, 17.304], + [90.56, 18.592], + [87.256, 22.12], + [86.08000000000001, 27.216000000000008], + [87.256, 32.36800000000001], + [90.50399999999999, 35.896], + [95.32, 37.184], + [95.32, 37.184], + [95.32, 37.184] + ], + "i": [ + [0, 0], + [-1.381, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.820999999999998, 1.493299999999991], + [1.419000000000011, 0.8586999999999989], + [1.754999999999995, 0], + [1.418999999999983, -0.8586999999999989], + [0.7839999999999918, -1.493300000000005], + [0, -1.904000000000011], + [-0.7839999999999918, -1.5307000000000102], + [-1.381, -0.8586999999999989], + [-1.792000000000002, 0], + [0, 0], + [0, 0] + ], + "o": [ + [1.754999999999995, 0], + [1.419000000000011, -0.8586999999999989], + [0.820999999999998, -1.5307000000000102], + [0, -1.904000000000011], + [-0.7839999999999918, -1.493300000000005], + [-1.381, -0.8586999999999989], + [-1.754999999999995, 0], + [-1.419000000000011, 0.8586999999999989], + [-0.7839999999999918, 1.493299999999991], + [0, 1.903999999999996], + [0.7839999999999918, 1.493299999999991], + [1.419000000000011, 0.8586999999999989], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [94.70400000000001, 43.06400000000001], + [86.864, 40.992], + [81.43199999999999, 35.28], + [79.47200000000001, 27.216000000000008], + [81.488, 19.152], + [86.91999999999999, 13.49600000000001], + [94.648, 11.424000000000007], + [101.088, 12.88], + [105.512, 16.912000000000006], + [104.56, 18.36800000000001], + [104.56, 0], + [110.832, 0], + [110.832, 42.392], + [104.84, 42.392], + [104.84, 36.232], + [105.568, 37.408], + [101.088, 41.608], + [94.70400000000001, 43.06400000000001], + [94.70400000000001, 43.06400000000001], + [94.70400000000001, 43.06400000000001] + ], + "i": [ + [0, 0], + [2.314999999999998, 1.381299999999996], + [1.344000000000023, 2.389300000000006], + [0, 2.986699999999999], + [-1.343999999999994, 2.389300000000006], + [-2.276999999999987, 1.381299999999996], + [-2.875, 0], + [-1.8669999999999902, -0.9706999999999937], + [-1.082999999999998, -1.717300000000009], + [0.3173333333333233, -0.4853333333333296], + [0, 6.122666666666674], + [-2.090666666666664, 0], + [0, -14.13066666666667], + [1.99733333333333, 0], + [0, 2.053333333333327], + [-0.242666666666679, -0.3919999999999959], + [1.941000000000003, -0.9707000000000079], + [2.314999999999998, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.912000000000006, 0], + [-2.277000000000015, -1.4187000000000012], + [-1.306999999999988, -2.389300000000006], + [0, -2.986699999999999], + [1.343999999999994, -2.389300000000006], + [2.277000000000015, -1.3813000000000102], + [2.426999999999992, 0], + [1.867000000000019, 0.9707000000000079], + [-0.3173333333333233, 0.4853333333333296], + [0, -6.122666666666674], + [2.090666666666664, 0], + [0, 14.13066666666667], + [-1.99733333333333, 0], + [0, -2.053333333333327], + [0.242666666666679, 0.3919999999999959], + [-1.045000000000016, 1.829300000000003], + [-1.941000000000003, 0.9706999999999937], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [57.40100000000001, 43.06400000000001], + [51.968999999999994, 42], + [48.38499999999999, 38.976], + [47.09700000000001, 34.384], + [48.161, 29.960000000000008], + [51.577, 26.656000000000006], + [57.345, 24.752], + [67.87299999999999, 23.016000000000005], + [67.87299999999999, 28], + [58.577, 29.624], + [54.881, 31.248], + [53.70500000000001, 34.16], + [54.992999999999995, 37.016000000000005], + [58.40899999999999, 38.08], + [62.88900000000001, 36.96000000000001], + [65.91300000000001, 33.992], + [66.977, 29.792], + [66.977, 22.008], + [65.297, 18.36800000000001], + [60.929, 16.912000000000006], + [56.505, 18.256], + [53.761, 21.616], + [48.496999999999986, 18.98400000000001], + [51.240999999999985, 15.064000000000007], + [55.66499999999999, 12.376], + [61.09700000000001, 11.424000000000007], + [67.42500000000001, 12.768], + [71.737, 16.52], + [73.305, 22.008], + [73.305, 42.392], + [67.257, 42.392], + [67.257, 36.904], + [68.54499999999999, 37.072], + [65.80099999999999, 40.264], + [62.04900000000001, 42.336], + [57.40100000000001, 43.06400000000001], + [57.40100000000001, 43.06400000000001], + [57.40100000000001, 43.06400000000001] + ], + "i": [ + [0, 0], + [1.568000000000012, 0.7092999999999989], + [0.8590000000000089, 1.2693000000000012], + [0, 1.7547], + [-0.7090000000000032, 1.306699999999992], + [-1.531000000000006, 0.8960000000000008], + [-2.314999999999998, 0.3733000000000004], + [-3.509333333333331, 0.5786666666666633], + [0, -1.661333333333332], + [3.098666666666674, -0.541333333333327], + [0.7839999999999918, -0.784000000000006], + [0, -1.194699999999997], + [-0.8590000000000089, -0.7467000000000041], + [-1.381, 0], + [-1.269000000000005, 0.7466999999999899], + [-0.7090000000000032, 1.2319999999999993], + [0, 1.530699999999996], + [0, 2.594666666666669], + [1.120000000000005, 0.9332999999999885], + [1.829000000000008, 0], + [1.269000000000005, -0.8960000000000008], + [0.5970000000000084, -1.381299999999996], + [1.754666666666679, 0.8773333333333255], + [-1.268999999999977, 1.11999999999999], + [-1.680000000000007, 0.6346999999999952], + [-1.903999999999996, 0], + [-1.829000000000008, -0.8960000000000008], + [-1.0080000000000098, -1.6053], + [0, -2.090699999999998], + [0, -6.794666666666672], + [2.015999999999991, 0], + [0, 1.829333333333338], + [-0.429333333333318, -0.05599999999999739], + [1.120000000000005, -0.8959999999999866], + [1.418999999999983, -0.4852999999999952], + [1.716999999999985, 0], + [0, 0], + [0, 0] + ], + "o": [ + [-2.053000000000026, 0], + [-1.531000000000006, -0.7467000000000041], + [-0.8589999999999804, -1.306699999999992], + [0, -1.642700000000005], + [0.7469999999999857, -1.306700000000006], + [1.531000000000006, -0.8960000000000008], + [3.509333333333331, -0.5786666666666633], + [0, 1.661333333333332], + [-3.098666666666674, 0.541333333333327], + [-1.680000000000007, 0.2987000000000108], + [-0.7839999999999918, 0.7467000000000041], + [0, 1.157300000000006], + [0.896000000000015, 0.7092999999999989], + [1.717000000000013, 0], + [1.306999999999988, -0.7467000000000041], + [0.7089999999999748, -1.2693000000000012], + [0, -2.594666666666669], + [0, -1.493299999999991], + [-1.082999999999998, -0.9707000000000079], + [-1.680000000000007, 0], + [-1.2319999999999993, 0.8586999999999989], + [-1.754666666666679, -0.8773333333333255], + [0.5600000000000023, -1.493300000000005], + [1.269000000000005, -1.157300000000006], + [1.717000000000013, -0.6347000000000094], + [2.388999999999982, 0], + [1.86699999999999, 0.8960000000000008], + [1.045000000000016, 1.568000000000012], + [0, 6.794666666666672], + [-2.015999999999991, 0], + [0, -1.829333333333338], + [0.429333333333318, 0.05599999999999739], + [-0.7089999999999748, 1.2319999999999993], + [-1.082999999999998, 0.8960000000000008], + [-1.381, 0.4853000000000094], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [0, 42.392], + [0, 0.6720000000000041], + [6.159999999999997, 0.6720000000000041], + [21.84, 22.400000000000006], + [18.75999999999999, 22.400000000000006], + [34.16, 0.6720000000000041], + [40.31999999999999, 0.6720000000000041], + [40.31999999999999, 42.392], + [33.768, 42.392], + [33.768, 8.456000000000003], + [36.232, 9.128], + [20.49600000000001, 30.632000000000005], + [19.824000000000012, 30.632000000000005], + [4.424000000000007, 9.128], + [6.608000000000004, 8.456000000000003], + [6.608000000000004, 42.392], + [0, 42.392], + [0, 42.392], + [0, 42.392] + ], + "i": [ + [0, 0], + [0, 13.90666666666666], + [-2.053333333333342, 0], + [-5.226666666666659, -7.242666666666665], + [1.026666666666671, 0], + [-5.133333333333326, 7.242666666666672], + [-2.053333333333342, 0], + [0, -13.90666666666667], + [2.183999999999997, 0], + [0, 11.312], + [-0.8213333333333424, -0.2240000000000038], + [5.245333333333321, -7.168000000000006], + [0.2239999999999895, 0], + [5.133333333333326, 7.168000000000006], + [-0.7280000000000086, 0.2240000000000038], + [0, -11.312], + [2.202666666666659, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, -13.90666666666667], + [2.053333333333342, 0], + [5.226666666666659, 7.242666666666672], + [-1.026666666666671, 0], + [5.133333333333326, -7.242666666666665], + [2.053333333333342, 0], + [0, 13.90666666666666], + [-2.183999999999997, 0], + [0, -11.312], + [0.8213333333333424, 0.2240000000000038], + [-5.245333333333321, 7.168000000000006], + [-0.2239999999999895, 0], + [-5.133333333333326, -7.168000000000006], + [0.7280000000000086, -0.2240000000000038], + [0, 11.312], + [-2.202666666666659, 0], + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [-211.7300415039062, -77.67320251464844], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "fl", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [2.91259765625, 56.001014709472656], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "gr", + "it": [ + { + "ty": "rc", + "d": 1, + "s": { "a": 0, "k": [702.6863719370097, 144], "ix": 2 }, + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "r": { "a": 0, "k": 72, "ix": 2 } + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0, 0, 0], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "r": 1, + "bm": 0 + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { "a": 0, "k": 100, "ix": 2 }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [56.54167175292969, -0.000022762338630855083], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [99.99999403953552, 99.99999403953552], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 80, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "tr", + "p": { "a": 0, "k": [122.0000003294881, 25.00000012138912], "ix": 2 }, + "a": { "a": 0, "k": [56.54167175292969, -0.00002288818359375], "ix": 2 }, + "s": { "a": 0, "k": [34.403572049765366, 34.403572049765366], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "line_06", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_06", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [49.732, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 163, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 183, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [159.64700317382812, 386.0762634277344], "ix": 2 }, + "a": { "a": 0, "k": [30.866, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "line_05", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_05", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [133.386, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 140, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 160, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [201.4739990234375, 305.88525390625], "ix": 2 }, + "a": { "a": 0, "k": [72.693, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "line_04", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_04", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [12, 12], + [217.895, 12] + ], + "i": [ + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 120, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 143, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [243.728515625, 225.6952362060547], "ix": 2 }, + "a": { "a": 0, "k": [114.9475, 12], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "line_03", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_03", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": true, + "v": [ + [-36.047, -71.491], + [-57.706, -49.832], + [-57.303, 12.081], + [-0.871, 71.477], + [0.204, 71.491], + [57.706, 13.985], + [57.304, -49.832], + [35.649, -71.491], + [-36.047, -71.491] + ], + "i": [ + [0, 0], + [0, -11.962], + [0, 0], + [-31.661, -0.575], + [-0.356, 0], + [0, 31.76], + [0, 0], + [11.962, 0], + [0, 0] + ], + "o": [ + [-11.962, 0], + [0, 0], + [0, 31.661], + [0.357, 0.01], + [31.761, 0], + [0, 0], + [0, -11.962], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 43, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "e": { "a": 0, "k": 0, "ix": 2 }, + "o": { + "a": 1, + "k": [ + { + "t": 0, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 43, + "s": [121.00000000000001], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [243, 93.62023162841797], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "line_02", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_02", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [186.878, 129.076], + [100.884, 215.071], + [-100.885, 215.071], + [-186.878, 129.076], + [-186.878, -129.076], + [-100.885, -215.07], + [-61.924, -215.07] + ], + "i": [ + [0, 0], + [47.496, 0], + [0, 0], + [0, 47.491], + [0, 0], + [-47.491, 0], + [0, 0] + ], + "o": [ + [0, 47.491], + [0, 0], + [-47.491, 0], + [0, 0], + [0, -47.491], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 100, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 33, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 70, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { + "a": 1, + "k": [ + { + "t": 33, + "s": [16], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 70, + "s": [126], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [245.79200744628906, 289.354736328125], "ix": 2 }, + "a": { "a": 0, "k": [0, 0.0004999999999881766], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "line_01", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "nm": "line_01", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [-63.344, -131.277], + [-22.649, -131.277], + [63.344, -45.283], + [63.344, 131.277] + ], + "i": [ + [0, 0], + [0, 0], + [0, -47.491], + [0, 0] + ], + "o": [ + [0, 0], + [47.492, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 50, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 127, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { + "a": 1, + "k": [ + { + "t": 50, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 87, + "s": [316], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 1, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [369.3269958496094, 205.56024169921875], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "correct", + "sr": 1, + "ks": { + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "gr", + "nm": "Group 1", + "it": [ + { + "ty": "sh", + "d": 1, + "ks": { + "a": 0, + "k": { + "c": false, + "v": [ + [-56.365, -4.925], + [-9.754, 41.501], + [56.365, -41.501] + ], + "i": [ + [0, 0], + [0, 0], + [0, 0] + ], + "o": [ + [0, 0], + [0, 0], + [0, 0] + ] + } + } + }, + { + "ty": "tm", + "s": { "a": 0, "k": 0, "ix": 2 }, + "e": { + "a": 1, + "k": [ + { + "t": 140, + "s": [0], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + }, + { + "t": 167, + "s": [100], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "o": { "a": 0, "k": 0, "ix": 2 }, + "m": 1 + }, + { + "ty": "st", + "c": { "a": 0, "k": [1, 1, 1], "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "w": { "a": 0, "k": 24, "ix": 2 }, + "lc": 2, + "lj": 2, + "ml": 4 + }, + { + "ty": "tr", + "p": { "a": 0, "k": [116.365, 101.5], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + }, + { + "ty": "tr", + "p": { + "a": 1, + "k": [ + { + "t": 140, + "s": [288.5679931640625, 424.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 2.167], + "to": [0, 2.667] + }, + { + "t": 167, + "s": [288.5679931640625, 440.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 1.333], + "to": [0, -2.167] + }, + { + "t": 180, + "s": [288.5679931640625, 411.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, -1.667], + "to": [0, -1.333] + }, + { + "t": 200, + "s": [288.5679931640625, 432.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 0.833], + "to": [0, 1.667] + }, + { + "t": 213, + "s": [288.5679931640625, 421.96725463867193], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, -0.5], + "to": [0, -0.833] + }, + { + "t": 227, + "s": [288.5679931640625, 427.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] }, + "ti": [0, 0.5], + "to": [0, 0.5] + }, + { + "t": 237, + "s": [288.5679931640625, 424.9672546386719], + "i": { "x": [0.75], "y": [0.75] }, + "o": { "x": [0.25], "y": [0.25] } + } + ], + "ix": 2 + }, + "a": { "a": 0, "k": [105.365, 142.5], "ix": 2 }, + "s": { "a": 0, "k": [100, 100], "ix": 2 }, + "r": { "a": 0, "k": 0, "ix": 2 }, + "o": { "a": 0, "k": 100, "ix": 2 }, + "sk": { "a": 0, "k": 0, "ix": 2 }, + "sa": { "a": 0, "k": 0, "ix": 2 } + } + ] + } + ], + "ip": 0, + "op": 251, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} diff --git a/frontend/public/lotties/search.json b/frontend/public/lotties/search.json new file mode 100644 index 000000000..a650cc5a0 --- /dev/null +++ b/frontend/public/lotties/search.json @@ -0,0 +1 @@ +{"v":"5.12.1","fr":60,"ip":0,"op":60,"w":500,"h":500,"nm":"system-regular-42-search","ddd":0,"assets":[{"id":"comp_1","nm":"hover-search","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":".primary.design","cl":"primary design","parent":2,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.995,147.901,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-41.707,-41.707],[41.707,41.707]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":41.73,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.218],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":1,"s":[0]},{"i":{"x":[0.35],"y":[1]},"o":{"x":[0.522],"y":[0]},"t":29,"s":[29]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":48,"s":[-4]},{"t":59,"s":[0]}],"ix":10},"p":{"a":1,"k":[{"i":{"x":0.218,"y":1},"o":{"x":0.333,"y":0},"t":1,"s":[223.962,223.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.35,"y":1},"o":{"x":0.522,"y":0},"t":29,"s":[310.962,188.958,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":48,"s":[203.962,233.958,0],"to":[0,0,0],"ti":[0,0,0]},{"t":59,"s":[223.962,223.958,0]}],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.218,0.218,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":1,"s":[100,100,100]},{"i":{"x":[0.35,0.35,0.667],"y":[1,1,1]},"o":{"x":[0.522,0.522,0.333],"y":[0,0,0]},"t":29,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":48,"s":[100,100,100]},{"t":59,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-80.542],[80.542,0],[0,80.542],[-80.542,0]],"o":[[0,80.542],[-80.542,0],[0,-80.542],[80.542,0]],"v":[[145.834,0],[0,145.834],[-145.834,0],[0,-145.834]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":3,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":31.3,"ix":5},"lc":2,"lj":2,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Stroke","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":1,"op":60,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":60,"op":384,"st":60,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":".primary.design","cl":"primary design","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[2083,2083,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,3.446],[-3.446,0],[0,-3.446],[3.446,0]],"o":[[0,-3.446],[3.446,0],[0,3.446],[-3.446,0]],"v":[[-7.5,-1.25],[-1.25,-7.5],[5,-1.25],[-1.25,5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.391,0.391],[0,0],[0,1.739],[4.273,0],[0,-4.273],[-4.273,0],[-1.322,1.049],[0,0],[-0.256,0],[-0.195,0.195]],"o":[[0,0],[0.971,-1.294],[0,-4.273],[-4.273,0],[0,4.273],[1.815,0],[0,0],[0.195,0.195],[0.256,0],[0.391,-0.391]],"v":[[8.707,7.144],[4.947,3.385],[6.5,-1.25],[-1.25,-9],[-9,-1.25],[-1.25,6.5],[3.554,4.82],[7.293,8.558],[8,8.851],[8.707,8.558]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.91,0.91,0.914,1],"ix":4,"x":"var $bm_rt;\n$bm_rt = comp('system-regular-42-search').layer('control').effect('primary')('Color');"},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":".primary","mn":"ADBE Vector Graphic - Fill","hd":false,"cl":"primary"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"control","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"ef":[{"ty":5,"nm":"primary","np":3,"mn":"ADBE Color Control","ix":1,"en":1,"ef":[{"ty":2,"nm":"Color","mn":"ADBE Color Control-0001","ix":1,"v":{"a":0,"k":[0.91,0.91,0.914],"ix":1}}]}],"ip":0,"op":131,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"hover-search","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2,"l":2},"a":{"a":0,"k":[250,250,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":500,"h":500,"ip":0,"op":70,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"default:hover-search","dr":60}],"props":{}} \ No newline at end of file diff --git a/frontend/src/components/auth/CodeInputStep.tsx b/frontend/src/components/auth/CodeInputStep.tsx index 09958fafd..f992c8da6 100644 --- a/frontend/src/components/auth/CodeInputStep.tsx +++ b/frontend/src/components/auth/CodeInputStep.tsx @@ -78,11 +78,14 @@ export default function CodeInputStep({ const resendVerificationEmail = async () => { setIsResendingVerificationEmail(true); setIsLoading(true); - await mutateAsync({ email }); - setTimeout(() => { - setIsLoading(false); - setIsResendingVerificationEmail(false); - }, 2000); + try { + await mutateAsync({ email }); + } finally { + setTimeout(() => { + setIsLoading(false); + setIsResendingVerificationEmail(false); + }, 1000); + } }; return ( diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx index 09537ce57..8ccdb0191 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/DeleteProjectTemplateModal.tsx @@ -41,7 +41,7 @@ export const DeleteProjectTemplateModal = ({ isOpen, onOpenChange, template }: P diff --git a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx index 9b7164f80..666edf87b 100644 --- a/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx +++ b/frontend/src/components/projects/ProjectSettings/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/EditProjectTemplate.tsx @@ -112,7 +112,7 @@ export const EditProjectTemplate = ({ isInfisicalTemplate, projectTemplate, onBa /> handlePopUpToggle("removeTemplate", isOpen)} onDeleteApproved={handleRemoveTemplate} diff --git a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx index 16f9a67d4..20b08eb49 100644 --- a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx +++ b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx @@ -66,7 +66,7 @@ export const DeleteSecretRotationV2Modal = ({ diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index a8a00e17f..969c4a049 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -62,6 +62,7 @@ const Content = ({ secretRotation }: ContentProps) => { let Component: ReactNode; switch (generatedCredentialsResponse.type) { case SecretRotation.PostgresCredentials: + case SecretRotation.MySqlCredentials: case SecretRotation.MsSqlCredentials: Component = ( = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 2bfdc16fd..98367eed3 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -15,6 +15,7 @@ import { SqlCredentialsRotationReviewFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationReviewFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index 9da51272b..f77dc99e5 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -12,6 +12,7 @@ import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, + [SecretRotation.MySqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationSecretsMappingFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index b8564801f..6d6fc64e2 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -5,6 +5,7 @@ import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; +import { MySqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; @@ -17,6 +18,7 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) => AzureClientSecretRotationSchema, PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, + MySqlCredentialsRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]), diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts new file mode 100644 index 000000000..7322615c2 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const MySqlCredentialsRotationSchema = z + .object({ + type: z.literal(SecretRotation.MySqlCredentials) + }) + .merge(SqlCredentialsRotationSchema) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..a3943cf35 --- /dev/null +++ b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { + SecretScanningDataSource, + TSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; +import { SecretScanningDataSourceSelect } from "./SecretScanningDataSourceSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (dataSource: TSecretScanningDataSource) => void; + selectedDataSource: SecretScanningDataSource | null; + setSelectedDataSource: (selectedDataSource: SecretScanningDataSource | null) => void; +}; + +const Content = ({ setSelectedDataSource, selectedDataSource, ...props }: ContentProps) => { + if (selectedDataSource) { + return ( + setSelectedDataSource(null)} + type={selectedDataSource} + {...props} + /> + ); + } + + return ; +}; + +export const CreateSecretScanningDataSourceModal = ({ onOpenChange, isOpen, ...props }: Props) => { + const [selectedDataSource, setSelectedDataSource] = useState( + null + ); + + return ( + { + if (!open) setSelectedDataSource(null); + onOpenChange(open); + }} + > + + ) : ( + + ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className={selectedDataSource ? "max-w-2xl" : "max-w-3xl"} + subTitle={ + selectedDataSource ? undefined : "Select a data source to configure secret scanning for." + } + bodyClassName="overflow-visible" + > + { + setSelectedDataSource(null); + onOpenChange(false); + }} + selectedDataSource={selectedDataSource} + setSelectedDataSource={setSelectedDataSource} + {...props} + /> + + + ); +}; diff --git a/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..9cf918dfb --- /dev/null +++ b/frontend/src/components/secret-scanning/DeleteSecretScanningDataSourceModal.tsx @@ -0,0 +1,66 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + TSecretScanningDataSource, + useDeleteSecretScanningDataSource +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + dataSource?: TSecretScanningDataSource; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretScanningDataSourceModal = ({ + isOpen, + onOpenChange, + dataSource, + onComplete +}: Props) => { + const deleteDataSource = useDeleteSecretScanningDataSource(); + + if (!dataSource) return null; + + const { id: dataSourceId, name, type, projectId } = dataSource; + + const handleDeleteDataSource = async () => { + const dataSourceType = SECRET_SCANNING_DATA_SOURCE_MAP[type].name; + + try { + await deleteDataSource.mutateAsync({ + dataSourceId, + type, + projectId + }); + + createNotification({ + text: `Successfully deleted ${dataSourceType} Data Source`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch { + createNotification({ + text: `Failed to delete ${dataSourceType} Data Source`, + type: "error" + }); + } + }; + + return ( + +

+ Findings associated with this data source will be preserved. +

+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx new file mode 100644 index 000000000..318a96bed --- /dev/null +++ b/frontend/src/components/secret-scanning/EditSecretScanningDataSourceModal.tsx @@ -0,0 +1,36 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { SecretScanningDataSourceForm } from "./forms"; +import { SecretScanningDataSourceModalHeader } from "./SecretScanningDataSourceModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + dataSource?: TSecretScanningDataSource; +}; + +export const EditSecretScanningDataSourceModal = ({ + dataSource, + onOpenChange, + ...props +}: Props) => { + if (!dataSource) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + dataSource={dataSource} + type={dataSource.type} + /> + + + ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx new file mode 100644 index 000000000..fb1f4236b --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceModalHeader.tsx @@ -0,0 +1,47 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +type Props = { + type: SecretScanningDataSource; + isConfigured: boolean; +}; + +export const SecretScanningDataSourceModalHeader = ({ type, isConfigured }: Props) => { + const dataSourceDetails = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( +
+ {`${dataSourceDetails.name} +
+
+ {dataSourceDetails.name} Data Source + +
+ + Docs + +
+
+
+

+ {isConfigured ? "Edit" : "Connect a"} {dataSourceDetails.name} Data Source +

+
+
+ ); +}; diff --git a/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx new file mode 100644 index 000000000..c507a6ef3 --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningDataSourceSelect.tsx @@ -0,0 +1,91 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_SCANNING_DATA_SOURCE_MAP } from "@app/helpers/secretScanningV2"; +import { + SecretScanningDataSource, + useSecretScanningDataSourceOptions +} from "@app/hooks/api/secretScanningV2"; + +type Props = { + onSelect: (type: SecretScanningDataSource) => void; +}; + +export const SecretScanningDataSourceSelect = ({ onSelect }: Props) => { + const { isPending, data: dataSourceOptions } = useSecretScanningDataSourceOptions(); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {dataSourceOptions?.map(({ type }) => { + const { image, name, size } = SECRET_SCANNING_DATA_SOURCE_MAP[type]; + + return ( + + ); + })} + +

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-scanning/SecretScanningScanStatus.tsx b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx new file mode 100644 index 000000000..c74ee477f --- /dev/null +++ b/frontend/src/components/secret-scanning/SecretScanningScanStatus.tsx @@ -0,0 +1,90 @@ +import { faArrowRotateForward, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { SecretScanningScanStatus } from "@app/hooks/api/secretScanningV2"; + +type Props = { + status: SecretScanningScanStatus; + statusMessage?: string | null; + className?: string; + scannedAt?: string | null; +}; + +export const SecretScanningScanStatusBadge = ({ + status, + statusMessage, + className, + scannedAt +}: Props) => { + if (status === SecretScanningScanStatus.Failed) { + let errorMessage = statusMessage; + if (statusMessage) { + try { + errorMessage = JSON.stringify(JSON.parse(statusMessage), null, 2); + } catch { + errorMessage = statusMessage; + } + } + + return ( + +
+
+ +
Failure Reason
+
+
{errorMessage}
+ {scannedAt && ( +
+ Attempted {formatDistance(new Date(scannedAt), new Date(), { addSuffix: true })} +
+ )} +
+ + } + > +
+ + + Scan Error + +
+
+ ); + } + + if (status === SecretScanningScanStatus.Queued || status === SecretScanningScanStatus.Scanning) { + return ( + + + Scanning + + ); + } + + return ( + + + Complete + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx new file mode 100644 index 000000000..be369bd07 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/GitHubDataSourceConfigFields.tsx @@ -0,0 +1,126 @@ +import { useEffect } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { MultiValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TGitHubRadarConnectionRepository, + useGitHubRadarConnectionListRepositories +} from "@app/hooks/api/appConnections/github-radar"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { SecretScanningDataSourceConnectionField } from "../SecretScanningDataSourceConnectionField"; + +enum ScanMethod { + AllRepositories = "all-repositories", + SelectRepositories = "select-repositories" +} + +export const GitHubDataSourceConfigFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretScanningDataSourceForm & { + type: SecretScanningDataSource.GitHub; + } + >(); + + const connectionId = useWatch({ control, name: "connection.id" }); + const isUpdate = Boolean(watch("id")); + + const { data: repositories, isPending: areRepositoriesLoading } = + useGitHubRadarConnectionListRepositories(connectionId, { enabled: Boolean(connectionId) }); + + const includeRepos = watch("config.includeRepos"); + + const scanMethod = + !includeRepos || includeRepos[0] === "*" + ? ScanMethod.AllRepositories + : ScanMethod.SelectRepositories; + + useEffect(() => { + if (!includeRepos) { + setValue("config.includeRepos", ["*"]); + } + }, [includeRepos, setValue]); + + return ( + <> + { + if (scanMethod === ScanMethod.SelectRepositories) { + setValue("config.includeRepos", []); + } + }} + /> + + + + {scanMethod === ScanMethod.SelectRepositories && ( + ( + Ensure that your connection has the correct permissions.} + > +
+ Don't see the repository you're looking for?{" "} + +
+ + } + > + value.includes(repository.name))} + onChange={(newValue) => { + onChange( + newValue + ? (newValue as MultiValue).map( + (p) => p.name + ) + : null + ); + }} + options={repositories} + placeholder="Select repositories..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> +
+ )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx new file mode 100644 index 000000000..bebcf28fc --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/SecretScanningDataSourceConfigFields.tsx @@ -0,0 +1,55 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Switch } from "@app/components/v2"; +import { RESOURCE_DESCRIPTION_HELPER } from "@app/helpers/secretScanningV2"; +import { SecretScanningDataSource } from "@app/hooks/api/secretScanningV2"; + +import { TSecretScanningDataSourceForm } from "../schemas"; +import { GitHubDataSourceConfigFields } from "./GitHubDataSourceConfigFields"; + +const COMPONENT_MAP: Record = { + [SecretScanningDataSource.GitHub]: GitHubDataSourceConfigFields +}; + +export const SecretScanningDataSourceConfigFields = () => { + const { watch, control } = useFormContext(); + + const type = watch("type"); + + const Component = COMPONENT_MAP[type]; + const autoScanDescription = RESOURCE_DESCRIPTION_HELPER[type]; + + return ( + <> +

Connect and configure your Data Source.

+ + { + return ( + + +

Auto-Scan {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts new file mode 100644 index 000000000..31d24284f --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConfigFields/index.ts @@ -0,0 +1 @@ +export * from "./SecretScanningDataSourceConfigFields"; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx new file mode 100644 index 000000000..3f995e1d0 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceConnectionField.tsx @@ -0,0 +1,103 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/helpers/secretScanningV2"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretScanningDataSourceForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; + isUpdate?: boolean; +}; + +export const SecretScanningDataSourceConnectionField = ({ + onChange: callback, + isUpdate +}: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const dataSourceType = watch("type"); + const app = SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSourceType]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + return ( + <> + ( + + Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret scanning. +

+ ) + } + > + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + isDisabled={isUpdate} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + control={control} + name="connection" + /> + {!isUpdate && availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {connectionName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${connectionName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx new file mode 100644 index 000000000..7c5565421 --- /dev/null +++ b/frontend/src/components/secret-scanning/forms/SecretScanningDataSourceDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TSecretScanningDataSourceForm } from "./schemas"; + +export const SecretScanningDataSourceDetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Data Source. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +