diff --git a/.github/workflows/check-non-re2-regex.yml b/.github/workflows/check-non-re2-regex.yml new file mode 100644 index 000000000..fef403fc5 --- /dev/null +++ b/.github/workflows/check-non-re2-regex.yml @@ -0,0 +1,53 @@ +name: Detect Non-RE2 Regex +on: + pull_request: + types: [opened, synchronize] + +jobs: + check-non-re2-regex: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get diff of backend/* + run: | + git diff --unified=0 "origin/${{ github.base_ref }}"...HEAD -- backend/ > diff.txt + + - name: Scan backend diff for non-RE2 regex + run: | + # Extract only added lines (excluding file headers) + grep '^+' diff.txt | grep -v '^+++' | sed 's/^\+//' > added_lines.txt + + if [ ! -s added_lines.txt ]; then + echo "✅ No added lines in backend/ to check for regex usage." + exit 0 + fi + + regex_usage_pattern='(^|[^A-Za-z0-9_"'"'"'`\.\/\\])(\/(?:\\.|[^\/\n\\])+\/[gimsuyv]*(?=\s*[\.\(;,)\]}:]|$)|new RegExp\()' + + # Find all added lines that contain regex patterns + if grep -E "$regex_usage_pattern" added_lines.txt > potential_violations.txt 2>/dev/null; then + # Filter out lines that contain 'new RE2' (allowing for whitespace variations) + if grep -v -E 'new\s+RE2\s*\(' potential_violations.txt > actual_violations.txt 2>/dev/null && [ -s actual_violations.txt ]; then + echo "🚨 ERROR: Found forbidden regex pattern in added/modified backend code." + echo "" + echo "The following lines use raw regex literals (/.../) or new RegExp(...):" + echo "Please replace with 'new RE2(...)' for RE2 compatibility." + echo "" + echo "Offending lines:" + cat actual_violations.txt + exit 1 + else + echo "✅ All identified regex usages are correctly using 'new RE2(...)'." + fi + else + echo "✅ No regex patterns found in added/modified backend lines." + fi + + - name: Cleanup temporary files + if: always() + run: | + rm -f diff.txt added_lines.txt potential_violations.txt actual_violations.txt diff --git a/.infisicalignore b/.infisicalignore index 546c67118..8cf00ac26 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -44,3 +44,4 @@ cli/detect/config/gitleaks.toml:gcp-api-key:582 .github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml:generic-api-key:50 .github/workflows/helm-release-infisical-core.yml:generic-api-key:48 .github/workflows/helm-release-infisical-core.yml:generic-api-key:47 +backend/src/services/smtp/smtp-service.ts:generic-api-key:79 diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index fc398c2ac..c0e18a763 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -84,6 +84,11 @@ const getZodDefaultValue = (type: unknown, value: string | number | boolean | Ob } }; +const bigIntegerColumns: Record = { + "folder_commits": ["commitId"] +}; + + const main = async () => { const tables = ( await db("information_schema.tables") @@ -108,6 +113,9 @@ const main = async () => { const columnName = columnNames[colNum]; const colInfo = columns[columnName]; let ztype = getZodPrimitiveType(colInfo.type); + if (bigIntegerColumns[tableName]?.includes(columnName)) { + ztype = "z.coerce.bigint()"; + } if (["zodBuffer"].includes(ztype)) { zodImportSet.add(ztype); } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 27f14fdb2..325da75e1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -26,6 +26,7 @@ import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-con import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; @@ -59,10 +60,12 @@ import { TCertificateTemplateServiceFactory } from "@app/services/certificate-te import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { THsmServiceFactory } from "@app/services/hsm/hsm-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { TIdentityAliCloudAuthServiceFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-service"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; @@ -119,6 +122,10 @@ declare module "@fastify/request-context" { oidc?: { claims: Record; }; + kubernetes?: { + namespace: string; + name: string; + }; }; identityPermissionMetadata?: Record; // filled by permission service assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string }; @@ -212,6 +219,7 @@ declare module "fastify" { identityUa: TIdentityUaServiceFactory; identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; + identityAliCloudAuth: TIdentityAliCloudAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOciAuth: TIdentityOciAuthServiceFactory; @@ -272,6 +280,8 @@ declare module "fastify" { microsoftTeams: TMicrosoftTeamsServiceFactory; assumePrivileges: TAssumePrivilegeServiceFactory; githubOrgSync: TGithubOrgSyncServiceFactory; + folderCommit: TFolderCommitServiceFactory; + pit: TPitServiceFactory; secretScanningV2: TSecretScanningV2ServiceFactory; internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory; pkiTemplate: TPkiTemplatesServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 44cd6bc79..1d4ad1797 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -80,6 +80,24 @@ import { TExternalKms, TExternalKmsInsert, TExternalKmsUpdate, + TFolderCheckpointResources, + TFolderCheckpointResourcesInsert, + TFolderCheckpointResourcesUpdate, + TFolderCheckpoints, + TFolderCheckpointsInsert, + TFolderCheckpointsUpdate, + TFolderCommitChanges, + TFolderCommitChangesInsert, + TFolderCommitChangesUpdate, + TFolderCommits, + TFolderCommitsInsert, + TFolderCommitsUpdate, + TFolderTreeCheckpointResources, + TFolderTreeCheckpointResourcesInsert, + TFolderTreeCheckpointResourcesUpdate, + TFolderTreeCheckpoints, + TFolderTreeCheckpointsInsert, + TFolderTreeCheckpointsUpdate, TGateways, TGatewaysInsert, TGatewaysUpdate, @@ -107,6 +125,9 @@ import { TIdentityAccessTokens, TIdentityAccessTokensInsert, TIdentityAccessTokensUpdate, + TIdentityAlicloudAuths, + TIdentityAlicloudAuthsInsert, + TIdentityAlicloudAuthsUpdate, TIdentityAwsAuths, TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate, @@ -768,6 +789,11 @@ declare module "knex/types/tables" { TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate >; + [TableName.IdentityAliCloudAuth]: KnexOriginal.CompositeTableType< + TIdentityAlicloudAuths, + TIdentityAlicloudAuthsInsert, + TIdentityAlicloudAuthsUpdate + >; [TableName.IdentityAwsAuth]: KnexOriginal.CompositeTableType< TIdentityAwsAuths, TIdentityAwsAuthsInsert, @@ -1122,6 +1148,36 @@ declare module "knex/types/tables" { TGithubOrgSyncConfigsInsert, TGithubOrgSyncConfigsUpdate >; + [TableName.FolderCommit]: KnexOriginal.CompositeTableType< + TFolderCommits, + TFolderCommitsInsert, + TFolderCommitsUpdate + >; + [TableName.FolderCommitChanges]: KnexOriginal.CompositeTableType< + TFolderCommitChanges, + TFolderCommitChangesInsert, + TFolderCommitChangesUpdate + >; + [TableName.FolderCheckpoint]: KnexOriginal.CompositeTableType< + TFolderCheckpoints, + TFolderCheckpointsInsert, + TFolderCheckpointsUpdate + >; + [TableName.FolderCheckpointResources]: KnexOriginal.CompositeTableType< + TFolderCheckpointResources, + TFolderCheckpointResourcesInsert, + TFolderCheckpointResourcesUpdate + >; + [TableName.FolderTreeCheckpoint]: KnexOriginal.CompositeTableType< + TFolderTreeCheckpoints, + TFolderTreeCheckpointsInsert, + TFolderTreeCheckpointsUpdate + >; + [TableName.FolderTreeCheckpointResources]: KnexOriginal.CompositeTableType< + TFolderTreeCheckpointResources, + TFolderTreeCheckpointResourcesInsert, + TFolderTreeCheckpointResourcesUpdate + >; [TableName.SecretScanningDataSource]: KnexOriginal.CompositeTableType< TSecretScanningDataSources, TSecretScanningDataSourcesInsert, diff --git a/backend/src/db/migrations/20250505194916_add-pit-revamp-tables.ts b/backend/src/db/migrations/20250505194916_add-pit-revamp-tables.ts new file mode 100644 index 000000000..30d1ec49b --- /dev/null +++ b/backend/src/db/migrations/20250505194916_add-pit-revamp-tables.ts @@ -0,0 +1,166 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const hasFolderCommitTable = await knex.schema.hasTable(TableName.FolderCommit); + if (!hasFolderCommitTable) { + await knex.schema.createTable(TableName.FolderCommit, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.bigIncrements("commitId"); + t.jsonb("actorMetadata").notNullable(); + t.string("actorType").notNullable(); + t.string("message"); + t.uuid("folderId").notNullable(); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderId"); + t.index("envId"); + }); + } + + const hasFolderCommitChangesTable = await knex.schema.hasTable(TableName.FolderCommitChanges); + if (!hasFolderCommitChangesTable) { + await knex.schema.createTable(TableName.FolderCommitChanges, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.uuid("folderCommitId").notNullable(); + t.foreign("folderCommitId").references("id").inTable(TableName.FolderCommit).onDelete("CASCADE"); + t.string("changeType").notNullable(); + t.boolean("isUpdate").notNullable().defaultTo(false); + t.uuid("secretVersionId"); + t.foreign("secretVersionId").references("id").inTable(TableName.SecretVersionV2).onDelete("CASCADE"); + t.uuid("folderVersionId"); + t.foreign("folderVersionId").references("id").inTable(TableName.SecretFolderVersion).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderCommitId"); + t.index("secretVersionId"); + t.index("folderVersionId"); + }); + } + + const hasFolderCheckpointTable = await knex.schema.hasTable(TableName.FolderCheckpoint); + if (!hasFolderCheckpointTable) { + await knex.schema.createTable(TableName.FolderCheckpoint, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.uuid("folderCommitId").notNullable(); + t.foreign("folderCommitId").references("id").inTable(TableName.FolderCommit).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderCommitId"); + }); + } + + const hasFolderCheckpointResourcesTable = await knex.schema.hasTable(TableName.FolderCheckpointResources); + if (!hasFolderCheckpointResourcesTable) { + await knex.schema.createTable(TableName.FolderCheckpointResources, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.uuid("folderCheckpointId").notNullable(); + t.foreign("folderCheckpointId").references("id").inTable(TableName.FolderCheckpoint).onDelete("CASCADE"); + t.uuid("secretVersionId"); + t.foreign("secretVersionId").references("id").inTable(TableName.SecretVersionV2).onDelete("CASCADE"); + t.uuid("folderVersionId"); + t.foreign("folderVersionId").references("id").inTable(TableName.SecretFolderVersion).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderCheckpointId"); + t.index("secretVersionId"); + t.index("folderVersionId"); + }); + } + + const hasFolderTreeCheckpointTable = await knex.schema.hasTable(TableName.FolderTreeCheckpoint); + if (!hasFolderTreeCheckpointTable) { + await knex.schema.createTable(TableName.FolderTreeCheckpoint, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.uuid("folderCommitId").notNullable(); + t.foreign("folderCommitId").references("id").inTable(TableName.FolderCommit).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderCommitId"); + }); + } + + const hasFolderTreeCheckpointResourcesTable = await knex.schema.hasTable(TableName.FolderTreeCheckpointResources); + if (!hasFolderTreeCheckpointResourcesTable) { + await knex.schema.createTable(TableName.FolderTreeCheckpointResources, (t) => { + t.uuid("id").primary().defaultTo(knex.fn.uuid()); + t.uuid("folderTreeCheckpointId").notNullable(); + t.foreign("folderTreeCheckpointId").references("id").inTable(TableName.FolderTreeCheckpoint).onDelete("CASCADE"); + t.uuid("folderId").notNullable(); + t.uuid("folderCommitId").notNullable(); + t.foreign("folderCommitId").references("id").inTable(TableName.FolderCommit).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.index("folderTreeCheckpointId"); + t.index("folderId"); + t.index("folderCommitId"); + }); + } + + if (!hasFolderCommitTable) { + await createOnUpdateTrigger(knex, TableName.FolderCommit); + } + + if (!hasFolderCommitChangesTable) { + await createOnUpdateTrigger(knex, TableName.FolderCommitChanges); + } + + if (!hasFolderCheckpointTable) { + await createOnUpdateTrigger(knex, TableName.FolderCheckpoint); + } + + if (!hasFolderCheckpointResourcesTable) { + await createOnUpdateTrigger(knex, TableName.FolderCheckpointResources); + } + + if (!hasFolderTreeCheckpointTable) { + await createOnUpdateTrigger(knex, TableName.FolderTreeCheckpoint); + } + + if (!hasFolderTreeCheckpointResourcesTable) { + await createOnUpdateTrigger(knex, TableName.FolderTreeCheckpointResources); + } +} + +export async function down(knex: Knex): Promise { + const hasFolderCheckpointResourcesTable = await knex.schema.hasTable(TableName.FolderCheckpointResources); + const hasFolderTreeCheckpointResourcesTable = await knex.schema.hasTable(TableName.FolderTreeCheckpointResources); + const hasFolderCommitTable = await knex.schema.hasTable(TableName.FolderCommit); + const hasFolderCommitChangesTable = await knex.schema.hasTable(TableName.FolderCommitChanges); + const hasFolderTreeCheckpointTable = await knex.schema.hasTable(TableName.FolderTreeCheckpoint); + const hasFolderCheckpointTable = await knex.schema.hasTable(TableName.FolderCheckpoint); + + if (hasFolderTreeCheckpointResourcesTable) { + await dropOnUpdateTrigger(knex, TableName.FolderTreeCheckpointResources); + await knex.schema.dropTableIfExists(TableName.FolderTreeCheckpointResources); + } + + if (hasFolderCheckpointResourcesTable) { + await dropOnUpdateTrigger(knex, TableName.FolderCheckpointResources); + await knex.schema.dropTableIfExists(TableName.FolderCheckpointResources); + } + + if (hasFolderTreeCheckpointTable) { + await dropOnUpdateTrigger(knex, TableName.FolderTreeCheckpoint); + await knex.schema.dropTableIfExists(TableName.FolderTreeCheckpoint); + } + + if (hasFolderCheckpointTable) { + await dropOnUpdateTrigger(knex, TableName.FolderCheckpoint); + await knex.schema.dropTableIfExists(TableName.FolderCheckpoint); + } + + if (hasFolderCommitChangesTable) { + await dropOnUpdateTrigger(knex, TableName.FolderCommitChanges); + await knex.schema.dropTableIfExists(TableName.FolderCommitChanges); + } + + if (hasFolderCommitTable) { + await dropOnUpdateTrigger(knex, TableName.FolderCommit); + await knex.schema.dropTableIfExists(TableName.FolderCommit); + } +} diff --git a/backend/src/db/migrations/20250528110936_add-folder-description-to-versioning.ts b/backend/src/db/migrations/20250528110936_add-folder-description-to-versioning.ts new file mode 100644 index 000000000..8c1da7ebc --- /dev/null +++ b/backend/src/db/migrations/20250528110936_add-folder-description-to-versioning.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretFolderVersion, "description"))) { + await knex.schema.alterTable(TableName.SecretFolderVersion, (t) => { + t.string("description").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretFolderVersion, "description")) { + await knex.schema.alterTable(TableName.SecretFolderVersion, (t) => { + t.dropColumn("description"); + }); + } +} diff --git a/backend/src/db/migrations/20250602155451_fix-secret-versions.ts b/backend/src/db/migrations/20250602155451_fix-secret-versions.ts new file mode 100644 index 000000000..f525e85f3 --- /dev/null +++ b/backend/src/db/migrations/20250602155451_fix-secret-versions.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-await-in-loop */ +import { Knex } from "knex"; + +import { chunkArray } from "@app/lib/fn"; +import { selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; + +import { SecretType, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + logger.info("Starting secret version fix migration"); + + // Get all shared secret IDs first to optimize versions query + const secretIds = await knex(TableName.SecretV2) + .where("type", SecretType.Shared) + .select("id") + .then((rows) => rows.map((row) => row.id)); + + logger.info(`Found ${secretIds.length} shared secrets to process`); + + if (secretIds.length === 0) { + logger.info("No shared secrets found"); + return; + } + + const secretIdChunks = chunkArray(secretIds, 5000); + + for (let chunkIndex = 0; chunkIndex < secretIdChunks.length; chunkIndex += 1) { + const currentSecretIds = secretIdChunks[chunkIndex]; + logger.info(`Processing chunk ${chunkIndex + 1} of ${secretIdChunks.length}`); + + // Get secrets and versions for current chunk + const [sharedSecrets, allVersions] = await Promise.all([ + knex(TableName.SecretV2).whereIn("id", currentSecretIds).select(selectAllTableCols(TableName.SecretV2)), + knex(TableName.SecretVersionV2).whereIn("secretId", currentSecretIds).select("secretId", "version") + ]); + + const versionsBySecretId = new Map(); + + allVersions.forEach((v) => { + const versions = versionsBySecretId.get(v.secretId); + if (versions) { + versions.push(v.version); + } else { + versionsBySecretId.set(v.secretId, [v.version]); + } + }); + + const versionsToAdd = []; + const secretsToUpdate = []; + + // Process each shared secret + for (const secret of sharedSecrets) { + const existingVersions = versionsBySecretId.get(secret.id) || []; + + if (existingVersions.length === 0) { + // No versions exist - add current version + versionsToAdd.push({ + secretId: secret.id, + version: secret.version, + key: secret.key, + encryptedValue: secret.encryptedValue, + encryptedComment: secret.encryptedComment, + reminderNote: secret.reminderNote, + reminderRepeatDays: secret.reminderRepeatDays, + skipMultilineEncoding: secret.skipMultilineEncoding, + metadata: secret.metadata, + folderId: secret.folderId, + actorType: "platform" + }); + } else { + const latestVersion = Math.max(...existingVersions); + + if (latestVersion !== secret.version) { + // Latest version doesn't match - create new version and update secret + const nextVersion = latestVersion + 1; + + versionsToAdd.push({ + secretId: secret.id, + version: nextVersion, + key: secret.key, + encryptedValue: secret.encryptedValue, + encryptedComment: secret.encryptedComment, + reminderNote: secret.reminderNote, + reminderRepeatDays: secret.reminderRepeatDays, + skipMultilineEncoding: secret.skipMultilineEncoding, + metadata: secret.metadata, + folderId: secret.folderId, + actorType: "platform" + }); + + secretsToUpdate.push({ + id: secret.id, + newVersion: nextVersion + }); + } + } + } + + logger.info( + `Chunk ${chunkIndex + 1}: Adding ${versionsToAdd.length} versions, updating ${secretsToUpdate.length} secrets` + ); + + // Batch insert new versions + if (versionsToAdd.length > 0) { + const insertBatches = chunkArray(versionsToAdd, 9000); + for (let i = 0; i < insertBatches.length; i += 1) { + await knex.batchInsert(TableName.SecretVersionV2, insertBatches[i]); + } + } + + if (secretsToUpdate.length > 0) { + const updateBatches = chunkArray(secretsToUpdate, 1000); + + for (const updateBatch of updateBatches) { + const ids = updateBatch.map((u) => u.id); + const versionCases = updateBatch.map((u) => `WHEN '${u.id}' THEN ${u.newVersion}`).join(" "); + + await knex.raw( + ` + UPDATE ${TableName.SecretV2} + SET version = CASE id ${versionCases} END, + "updatedAt" = NOW() + WHERE id IN (${ids.map(() => "?").join(",")}) + `, + ids + ); + } + } + } + + logger.info("Secret version fix migration completed"); +} + +export async function down(): Promise { + logger.info("Rollback not implemented for secret version fix migration"); + // Note: Rolling back this migration would be complex and potentially destructive + // as it would require tracking which version entries were added +} diff --git a/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts new file mode 100644 index 000000000..dd4034c57 --- /dev/null +++ b/backend/src/db/migrations/20250602155452_pit-projects-commits-initialization.ts @@ -0,0 +1,345 @@ +import { Knex } from "knex"; + +import { chunkArray } from "@app/lib/fn"; +import { selectAllTableCols } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; +import { ChangeType } from "@app/services/folder-commit/folder-commit-service"; + +import { + ProjectType, + SecretType, + TableName, + TFolderCheckpoints, + TFolderCommits, + TFolderTreeCheckpoints, + TSecretFolders +} from "../schemas"; + +const sortFoldersByHierarchy = (folders: TSecretFolders[]) => { + // Create a map for quick lookup of children by parent ID + const childrenMap = new Map(); + + // Set of all folder IDs + const allFolderIds = new Set(); + + // Build the set of all folder IDs + folders.forEach((folder) => { + if (folder.id) { + allFolderIds.add(folder.id); + } + }); + + // Group folders by their parentId + folders.forEach((folder) => { + if (folder.parentId) { + const children = childrenMap.get(folder.parentId) || []; + children.push(folder); + childrenMap.set(folder.parentId, children); + } + }); + + // Find root folders - those with no parentId or with a parentId that doesn't exist + const rootFolders = folders.filter((folder) => !folder.parentId || !allFolderIds.has(folder.parentId)); + + // Process each level of the hierarchy + const result = []; + let currentLevel = rootFolders; + + while (currentLevel.length > 0) { + result.push(...currentLevel); + + const nextLevel = []; + for (const folder of currentLevel) { + if (folder.id) { + const children = childrenMap.get(folder.id) || []; + nextLevel.push(...children); + } + } + + currentLevel = nextLevel; + } + + return result.reverse(); +}; + +const getSecretsByFolderIds = async (knex: Knex, folderIds: string[]): Promise> => { + const secrets = await knex(TableName.SecretV2) + .whereIn(`${TableName.SecretV2}.folderId`, folderIds) + .where(`${TableName.SecretV2}.type`, SecretType.Shared) + .join(TableName.SecretVersionV2, (queryBuilder) => { + void queryBuilder + .on(`${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) + .andOn(`${TableName.SecretVersionV2}.version`, `${TableName.SecretV2}.version`); + }) + .select(selectAllTableCols(TableName.SecretV2)) + .select(knex.ref("id").withSchema(TableName.SecretVersionV2).as("secretVersionId")); + + const secretsMap: Record = {}; + + secrets.forEach((secret) => { + if (!secretsMap[secret.folderId]) { + secretsMap[secret.folderId] = []; + } + secretsMap[secret.folderId].push(secret.secretVersionId); + }); + + return secretsMap; +}; + +const getFoldersByParentIds = async (knex: Knex, parentIds: string[]): Promise> => { + const folders = await knex(TableName.SecretFolder) + .whereIn(`${TableName.SecretFolder}.parentId`, parentIds) + .where(`${TableName.SecretFolder}.isReserved`, false) + .join(TableName.SecretFolderVersion, (queryBuilder) => { + void queryBuilder + .on(`${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`) + .andOn(`${TableName.SecretFolderVersion}.version`, `${TableName.SecretFolder}.version`); + }) + .select(selectAllTableCols(TableName.SecretFolder)) + .select(knex.ref("id").withSchema(TableName.SecretFolderVersion).as("folderVersionId")); + + const foldersMap: Record = {}; + + folders.forEach((folder) => { + if (!folder.parentId) { + return; + } + if (!foldersMap[folder.parentId]) { + foldersMap[folder.parentId] = []; + } + foldersMap[folder.parentId].push(folder.folderVersionId); + }); + + return foldersMap; +}; + +export async function up(knex: Knex): Promise { + logger.info("Initializing folder commits"); + const hasFolderCommitTable = await knex.schema.hasTable(TableName.FolderCommit); + if (hasFolderCommitTable) { + // Get Projects to Initialize + const projects = await knex(TableName.Project) + .where(`${TableName.Project}.version`, 3) + .where(`${TableName.Project}.type`, ProjectType.SecretManager) + .select(selectAllTableCols(TableName.Project)); + logger.info(`Found ${projects.length} projects to initialize`); + + // Process Projects in batches of 100 + const batches = chunkArray(projects, 100); + let i = 0; + for (const batch of batches) { + i += 1; + logger.info(`Processing project batch ${i} of ${batches.length}`); + let foldersCommitsList = []; + + const rootFoldersMap: Record = {}; + const envRootFoldersMap: Record = {}; + + // Get All Folders for the Project + // eslint-disable-next-line no-await-in-loop + const folders = await knex(TableName.SecretFolder) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .whereIn( + `${TableName.Environment}.projectId`, + batch.map((project) => project.id) + ) + .where(`${TableName.SecretFolder}.isReserved`, false) + .select(selectAllTableCols(TableName.SecretFolder)); + logger.info(`Found ${folders.length} folders to initialize in project batch ${i} of ${batches.length}`); + + // Sort Folders by Hierarchy (parents before nested folders) + const sortedFolders = sortFoldersByHierarchy(folders); + + // eslint-disable-next-line no-await-in-loop + const folderSecretsMap = await getSecretsByFolderIds( + knex, + sortedFolders.map((folder) => folder.id) + ); + // eslint-disable-next-line no-await-in-loop + const folderFoldersMap = await getFoldersByParentIds( + knex, + sortedFolders.map((folder) => folder.id) + ); + + // Get folder commit changes + for (const folder of sortedFolders) { + const subFolderVersionIds = folderFoldersMap[folder.id]; + const secretVersionIds = folderSecretsMap[folder.id]; + const changes = []; + if (subFolderVersionIds) { + changes.push( + ...subFolderVersionIds.map((folderVersionId) => ({ + folderId: folder.id, + changeType: ChangeType.ADD, + secretVersionId: undefined, + folderVersionId, + isUpdate: false + })) + ); + } + if (secretVersionIds) { + changes.push( + ...secretVersionIds.map((secretVersionId) => ({ + folderId: folder.id, + changeType: ChangeType.ADD, + secretVersionId, + folderVersionId: undefined, + isUpdate: false + })) + ); + } + if (changes.length > 0) { + const folderCommit = { + commit: { + actorMetadata: {}, + actorType: ActorType.PLATFORM, + message: "Initialized folder", + folderId: folder.id, + envId: folder.envId + }, + changes + }; + foldersCommitsList.push(folderCommit); + if (!folder.parentId) { + rootFoldersMap[folder.id] = folder.envId; + envRootFoldersMap[folder.envId] = folder.id; + } + } + } + logger.info(`Retrieved folder changes for project batch ${i} of ${batches.length}`); + + const filteredBrokenProjectFolders: string[] = []; + + foldersCommitsList = foldersCommitsList.filter((folderCommit) => { + if (!envRootFoldersMap[folderCommit.commit.envId]) { + filteredBrokenProjectFolders.push(folderCommit.commit.folderId); + return false; + } + return true; + }); + + logger.info( + `Filtered ${filteredBrokenProjectFolders.length} broken project folders: ${JSON.stringify(filteredBrokenProjectFolders)}` + ); + + // Insert New Commits in batches of 9000 + const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit); + const commitBatches = chunkArray(newCommits, 9000); + + let j = 0; + for (const commitBatch of commitBatches) { + j += 1; + logger.info(`Inserting folder commits - batch ${j} of ${commitBatches.length}`); + // Create folder commit + // eslint-disable-next-line no-await-in-loop + const newCommitsInserted = (await knex + .batchInsert(TableName.FolderCommit, commitBatch) + .returning("*")) as TFolderCommits[]; + + logger.info(`Finished inserting folder commits - batch ${j} of ${commitBatches.length}`); + + const newCommitsMap: Record = {}; + const newCommitsMapInverted: Record = {}; + const newCheckpointsMap: Record = {}; + newCommitsInserted.forEach((commit) => { + newCommitsMap[commit.folderId] = commit.id; + newCommitsMapInverted[commit.id] = commit.folderId; + }); + + // Create folder checkpoints + // eslint-disable-next-line no-await-in-loop + const newCheckpoints = (await knex + .batchInsert( + TableName.FolderCheckpoint, + Object.values(newCommitsMap).map((commitId) => ({ + folderCommitId: commitId + })) + ) + .returning("*")) as TFolderCheckpoints[]; + + logger.info(`Finished inserting folder checkpoints - batch ${j} of ${commitBatches.length}`); + + newCheckpoints.forEach((checkpoint) => { + newCheckpointsMap[newCommitsMapInverted[checkpoint.folderCommitId]] = checkpoint.id; + }); + + // Create folder commit changes + // eslint-disable-next-line no-await-in-loop + await knex.batchInsert( + TableName.FolderCommitChanges, + foldersCommitsList + .map((folderCommit) => folderCommit.changes) + .flat() + .map((change) => ({ + folderCommitId: newCommitsMap[change.folderId], + changeType: change.changeType, + secretVersionId: change.secretVersionId, + folderVersionId: change.folderVersionId, + isUpdate: false + })) + ); + + logger.info(`Finished inserting folder commit changes - batch ${j} of ${commitBatches.length}`); + + // Create folder checkpoint resources + // eslint-disable-next-line no-await-in-loop + await knex.batchInsert( + TableName.FolderCheckpointResources, + foldersCommitsList + .map((folderCommit) => folderCommit.changes) + .flat() + .map((change) => ({ + folderCheckpointId: newCheckpointsMap[change.folderId], + folderVersionId: change.folderVersionId, + secretVersionId: change.secretVersionId + })) + ); + + logger.info(`Finished inserting folder checkpoint resources - batch ${j} of ${commitBatches.length}`); + + // Create Folder Tree Checkpoint + // eslint-disable-next-line no-await-in-loop + const newTreeCheckpoints = (await knex + .batchInsert( + TableName.FolderTreeCheckpoint, + Object.keys(rootFoldersMap).map((folderId) => ({ + folderCommitId: newCommitsMap[folderId] + })) + ) + .returning("*")) as TFolderTreeCheckpoints[]; + + logger.info(`Finished inserting folder tree checkpoints - batch ${j} of ${commitBatches.length}`); + + const newTreeCheckpointsMap: Record = {}; + newTreeCheckpoints.forEach((checkpoint) => { + newTreeCheckpointsMap[rootFoldersMap[newCommitsMapInverted[checkpoint.folderCommitId]]] = checkpoint.id; + }); + + // Create Folder Tree Checkpoint Resources + // eslint-disable-next-line no-await-in-loop + await knex + .batchInsert( + TableName.FolderTreeCheckpointResources, + newCommitsInserted.map((folderCommit) => ({ + folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId], + folderId: folderCommit.folderId, + folderCommitId: folderCommit.id + })) + ) + .returning("*"); + + logger.info(`Finished inserting folder tree checkpoint resources - batch ${j} of ${commitBatches.length}`); + } + } + } + logger.info("Folder commits initialized"); +} + +export async function down(knex: Knex): Promise { + const hasFolderCommitTable = await knex.schema.hasTable(TableName.FolderCommit); + if (hasFolderCommitTable) { + // delete all existing entries + await knex(TableName.FolderCommit).del(); + } +} 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/migrations/20250606134139_add-project-snapshots-legacy-option.ts b/backend/src/db/migrations/20250606134139_add-project-snapshots-legacy-option.ts new file mode 100644 index 000000000..f5f73d7fe --- /dev/null +++ b/backend/src/db/migrations/20250606134139_add-project-snapshots-legacy-option.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasShowSnapshotsLegacyColumn = await knex.schema.hasColumn(TableName.Project, "showSnapshotsLegacy"); + if (!hasShowSnapshotsLegacyColumn) { + await knex.schema.table(TableName.Project, (table) => { + table.boolean("showSnapshotsLegacy").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasShowSnapshotsLegacyColumn = await knex.schema.hasColumn(TableName.Project, "showSnapshotsLegacy"); + if (hasShowSnapshotsLegacyColumn) { + await knex.schema.table(TableName.Project, (table) => { + table.dropColumn("showSnapshotsLegacy"); + }); + } +} diff --git a/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts b/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts new file mode 100644 index 000000000..30d6f1854 --- /dev/null +++ b/backend/src/db/migrations/20250610143920_add-dynamic-secret-lease-config.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConfigColumn = await knex.schema.hasColumn(TableName.DynamicSecretLease, "config"); + if (!hasConfigColumn) { + await knex.schema.alterTable(TableName.DynamicSecretLease, (table) => { + table.jsonb("config"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasConfigColumn = await knex.schema.hasColumn(TableName.DynamicSecretLease, "config"); + if (hasConfigColumn) { + await knex.schema.alterTable(TableName.DynamicSecretLease, (table) => { + table.dropColumn("config"); + }); + } +} diff --git a/backend/src/db/migrations/20250610173646_identity-kubernetes-auth-optional-host-url.ts b/backend/src/db/migrations/20250610173646_identity-kubernetes-auth-optional-host-url.ts new file mode 100644 index 000000000..6d2dd6664 --- /dev/null +++ b/backend/src/db/migrations/20250610173646_identity-kubernetes-auth-optional-host-url.ts @@ -0,0 +1,45 @@ +import { Knex } from "knex"; + +import { selectAllTableCols } from "@app/lib/knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + const hasKubernetesHostColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "kubernetesHost"); + + if (hasKubernetesHostColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.string("kubernetesHost").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasKubernetesHostColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "kubernetesHost"); + + // find all rows where kubernetesHost is null + const rows = await knex(TableName.IdentityKubernetesAuth) + .whereNull("kubernetesHost") + .select(selectAllTableCols(TableName.IdentityKubernetesAuth)); + + if (rows.length > 0) { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + // eslint-disable-next-line no-await-in-loop + await knex(TableName.IdentityKubernetesAuth) + .whereIn( + "id", + batch.map((row) => row.id) + ) + .update({ kubernetesHost: "" }); + } + } + + if (hasKubernetesHostColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.string("kubernetesHost").notNullable().alter(); + }); + } +} diff --git a/backend/src/db/migrations/20250610205158_alicloud-machine-identity.ts b/backend/src/db/migrations/20250610205158_alicloud-machine-identity.ts new file mode 100644 index 000000000..43832dc23 --- /dev/null +++ b/backend/src/db/migrations/20250610205158_alicloud-machine-identity.ts @@ -0,0 +1,29 @@ +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.IdentityAliCloudAuth))) { + await knex.schema.createTable(TableName.IdentityAliCloudAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("type").notNullable(); + + t.string("allowedArns").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAliCloudAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAliCloudAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAliCloudAuth); +} diff --git a/backend/src/db/migrations/utils/services.ts b/backend/src/db/migrations/utils/services.ts index 731f703e2..0e071e6fe 100644 --- a/backend/src/db/migrations/utils/services.ts +++ b/backend/src/db/migrations/utils/services.ts @@ -3,12 +3,27 @@ import { Knex } from "knex"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; +import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; +import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; +import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; +import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; +import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { identityDALFactory } from "@app/services/identity/identity-dal"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { orgDALFactory } from "@app/services/org/org-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; +import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; +import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; +import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { userDALFactory } from "@app/services/user/user-dal"; import { TMigrationEnvConfig } from "./env-config"; @@ -50,3 +65,77 @@ export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore } return { kmsService }; }; + +export const getMigrationPITServices = async ({ + db, + keyStore, + envConfig +}: { + db: Knex; + keyStore: TKeyStoreFactory; + envConfig: TMigrationEnvConfig; +}) => { + const projectDAL = projectDALFactory(db); + const folderCommitDAL = folderCommitDALFactory(db); + const folderCommitChangesDAL = folderCommitChangesDALFactory(db); + const folderCheckpointDAL = folderCheckpointDALFactory(db); + const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); + const userDAL = userDALFactory(db); + const identityDAL = identityDALFactory(db); + const folderDAL = secretFolderDALFactory(db); + const folderVersionDAL = secretFolderVersionDALFactory(db); + const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); + const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db); + const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); + const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); + const secretTagDAL = secretTagDALFactory(db); + + const orgDAL = orgDALFactory(db); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const kmsDAL = kmskeyDALFactory(db); + const internalKmsDAL = internalKmsDALFactory(db); + const resourceMetadataDAL = resourceMetadataDALFactory(db); + + const hsmModule = initializeHsmModule(envConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig + }); + + const kmsService = kmsServiceFactory({ + kmsRootConfigDAL, + keyStore, + kmsDAL, + internalKmsDAL, + orgDAL, + projectDAL, + hsmService, + envConfig + }); + + await hsmService.startService(); + await kmsService.startService(); + + const folderCommitService = folderCommitServiceFactory({ + folderCommitDAL, + folderCommitChangesDAL, + folderCheckpointDAL, + folderTreeCheckpointDAL, + userDAL, + identityDAL, + folderDAL, + folderVersionDAL, + secretVersionV2BridgeDAL, + projectDAL, + folderCheckpointResourcesDAL, + secretV2BridgeDAL, + folderTreeCheckpointResourcesDAL, + kmsService, + secretTagDAL, + resourceMetadataDAL + }); + + return { folderCommitService }; +}; diff --git a/backend/src/db/schemas/dynamic-secret-leases.ts b/backend/src/db/schemas/dynamic-secret-leases.ts index 8c16bcb55..ef16b1a30 100644 --- a/backend/src/db/schemas/dynamic-secret-leases.ts +++ b/backend/src/db/schemas/dynamic-secret-leases.ts @@ -16,7 +16,8 @@ export const DynamicSecretLeasesSchema = z.object({ statusDetails: z.string().nullable().optional(), dynamicSecretId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + config: z.unknown().nullable().optional() }); export type TDynamicSecretLeases = z.infer; diff --git a/backend/src/db/schemas/folder-checkpoint-resources.ts b/backend/src/db/schemas/folder-checkpoint-resources.ts new file mode 100644 index 000000000..5fa8215cf --- /dev/null +++ b/backend/src/db/schemas/folder-checkpoint-resources.ts @@ -0,0 +1,23 @@ +// 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 FolderCheckpointResourcesSchema = z.object({ + id: z.string().uuid(), + folderCheckpointId: z.string().uuid(), + secretVersionId: z.string().uuid().nullable().optional(), + folderVersionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderCheckpointResources = z.infer; +export type TFolderCheckpointResourcesInsert = Omit, TImmutableDBKeys>; +export type TFolderCheckpointResourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/folder-checkpoints.ts b/backend/src/db/schemas/folder-checkpoints.ts new file mode 100644 index 000000000..ba0ce6f71 --- /dev/null +++ b/backend/src/db/schemas/folder-checkpoints.ts @@ -0,0 +1,19 @@ +// 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 FolderCheckpointsSchema = z.object({ + id: z.string().uuid(), + folderCommitId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderCheckpoints = z.infer; +export type TFolderCheckpointsInsert = Omit, TImmutableDBKeys>; +export type TFolderCheckpointsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/folder-commit-changes.ts b/backend/src/db/schemas/folder-commit-changes.ts new file mode 100644 index 000000000..2bee0c5b3 --- /dev/null +++ b/backend/src/db/schemas/folder-commit-changes.ts @@ -0,0 +1,23 @@ +// 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 FolderCommitChangesSchema = z.object({ + id: z.string().uuid(), + folderCommitId: z.string().uuid(), + changeType: z.string(), + isUpdate: z.boolean().default(false), + secretVersionId: z.string().uuid().nullable().optional(), + folderVersionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderCommitChanges = z.infer; +export type TFolderCommitChangesInsert = Omit, TImmutableDBKeys>; +export type TFolderCommitChangesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/folder-commits.ts b/backend/src/db/schemas/folder-commits.ts new file mode 100644 index 000000000..ade480eda --- /dev/null +++ b/backend/src/db/schemas/folder-commits.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 FolderCommitsSchema = z.object({ + id: z.string().uuid(), + commitId: z.coerce.bigint(), + actorMetadata: z.unknown(), + actorType: z.string(), + message: z.string().nullable().optional(), + folderId: z.string().uuid(), + envId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderCommits = z.infer; +export type TFolderCommitsInsert = Omit, TImmutableDBKeys>; +export type TFolderCommitsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/folder-tree-checkpoint-resources.ts b/backend/src/db/schemas/folder-tree-checkpoint-resources.ts new file mode 100644 index 000000000..06d5770cc --- /dev/null +++ b/backend/src/db/schemas/folder-tree-checkpoint-resources.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 FolderTreeCheckpointResourcesSchema = z.object({ + id: z.string().uuid(), + folderTreeCheckpointId: z.string().uuid(), + folderId: z.string().uuid(), + folderCommitId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderTreeCheckpointResources = z.infer; +export type TFolderTreeCheckpointResourcesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TFolderTreeCheckpointResourcesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/folder-tree-checkpoints.ts b/backend/src/db/schemas/folder-tree-checkpoints.ts new file mode 100644 index 000000000..ea500af6b --- /dev/null +++ b/backend/src/db/schemas/folder-tree-checkpoints.ts @@ -0,0 +1,19 @@ +// 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 FolderTreeCheckpointsSchema = z.object({ + id: z.string().uuid(), + folderCommitId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TFolderTreeCheckpoints = z.infer; +export type TFolderTreeCheckpointsInsert = Omit, TImmutableDBKeys>; +export type TFolderTreeCheckpointsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-alicloud-auths.ts b/backend/src/db/schemas/identity-alicloud-auths.ts new file mode 100644 index 000000000..37950d3cf --- /dev/null +++ b/backend/src/db/schemas/identity-alicloud-auths.ts @@ -0,0 +1,25 @@ +// 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 IdentityAlicloudAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + type: z.string(), + allowedArns: z.string() +}); + +export type TIdentityAlicloudAuths = z.infer; +export type TIdentityAlicloudAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAlicloudAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 00d1fd771..deb78bf8a 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -18,7 +18,7 @@ export const IdentityKubernetesAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), identityId: z.string().uuid(), - kubernetesHost: z.string(), + kubernetesHost: z.string().nullable().optional(), encryptedCaCert: z.string().nullable().optional(), caCertIV: z.string().nullable().optional(), caCertTag: z.string().nullable().optional(), @@ -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 6743a23cc..292551c80 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -24,6 +24,12 @@ export * from "./dynamic-secrets"; export * from "./external-certificate-authorities"; export * from "./external-group-org-role-mappings"; export * from "./external-kms"; +export * from "./folder-checkpoint-resources"; +export * from "./folder-checkpoints"; +export * from "./folder-commit-changes"; +export * from "./folder-commits"; +export * from "./folder-tree-checkpoint-resources"; +export * from "./folder-tree-checkpoints"; export * from "./gateways"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; @@ -33,6 +39,7 @@ export * from "./group-project-memberships"; export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; +export * from "./identity-alicloud-auths"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 6722ce235..ceba6e370 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -80,6 +80,7 @@ export enum TableName { IdentityGcpAuth = "identity_gcp_auths", IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", + IdentityAliCloudAuth = "identity_alicloud_auths", IdentityAwsAuth = "identity_aws_auths", IdentityOciAuth = "identity_oci_auths", IdentityOidcAuth = "identity_oidc_auths", @@ -160,6 +161,12 @@ export enum TableName { ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs", SecretReminderRecipients = "secret_reminder_recipients", GithubOrgSyncConfig = "github_org_sync_configs", + FolderCommit = "folder_commits", + FolderCommitChanges = "folder_commit_changes", + FolderCheckpoint = "folder_checkpoints", + FolderCheckpointResources = "folder_checkpoint_resources", + FolderTreeCheckpoint = "folder_tree_checkpoints", + FolderTreeCheckpointResources = "folder_tree_checkpoint_resources", SecretScanningDataSource = "secret_scanning_data_sources", SecretScanningResource = "secret_scanning_resources", SecretScanningScan = "secret_scanning_scans", @@ -167,7 +174,7 @@ export enum TableName { SecretScanningConfig = "secret_scanning_configs" } -export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; +export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; export const UserDeviceSchema = z .object({ @@ -241,6 +248,7 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", + ALICLOUD_AUTH = "alicloud-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", OCI_AUTH = "oci-auth", diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index c1e96e8ce..b4c98d8a2 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -28,7 +28,8 @@ export const ProjectsSchema = z.object({ type: z.string(), enforceCapitalization: z.boolean().default(false), hasDeleteProtection: z.boolean().default(false).nullable().optional(), - secretSharing: z.boolean().default(true) + secretSharing: z.boolean().default(true), + showSnapshotsLegacy: z.boolean().default(false) }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/secret-folder-versions.ts b/backend/src/db/schemas/secret-folder-versions.ts index 8bef6e83f..3d444c566 100644 --- a/backend/src/db/schemas/secret-folder-versions.ts +++ b/backend/src/db/schemas/secret-folder-versions.ts @@ -14,7 +14,8 @@ export const SecretFolderVersionsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), envId: z.string().uuid(), - folderId: z.string().uuid() + folderId: z.string().uuid(), + description: z.string().nullable().optional() }); export type TSecretFolderVersions = z.infer; diff --git a/backend/src/ee/routes/v1/app-connection-routers/oracledb-connection-router.ts b/backend/src/ee/routes/v1/app-connection-routers/oracledb-connection-router.ts new file mode 100644 index 000000000..8a90fe9a7 --- /dev/null +++ b/backend/src/ee/routes/v1/app-connection-routers/oracledb-connection-router.ts @@ -0,0 +1,17 @@ +import { + CreateOracleDBConnectionSchema, + SanitizedOracleDBConnectionSchema, + UpdateOracleDBConnectionSchema +} from "@app/ee/services/app-connections/oracledb"; +import { registerAppConnectionEndpoints } from "@app/server/routes/v1/app-connection-routers/app-connection-endpoints"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const registerOracleDBConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.OracleDB, + server, + sanitizedResponseSchema: SanitizedOracleDBConnectionSchema, + createSchema: CreateOracleDBConnectionSchema, + updateSchema: UpdateOracleDBConnectionSchema + }); +}; diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts index 7c42c7f99..26c27d0d3 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts @@ -36,7 +36,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); }), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), - environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path) + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), + config: z.any().optional() }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts new file mode 100644 index 000000000..f2751c635 --- /dev/null +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { DynamicSecretLeasesSchema } from "@app/db/schemas"; +import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; +import { daysToMillisecond } from "@app/lib/dates"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerKubernetesDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.DynamicSecrets], + body: z.object({ + dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(), + projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug), + ttl: z + .string() + .optional() + .describe(DYNAMIC_SECRET_LEASES.CREATE.ttl) + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" }); + if (valMs > daysToMillisecond(1)) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), + environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), + config: z + .object({ + namespace: z.string().min(1).optional().describe(DYNAMIC_SECRET_LEASES.KUBERNETES.CREATE.config.namespace) + }) + .optional() + }), + response: { + 200: z.object({ + lease: DynamicSecretLeasesSchema, + dynamicSecret: SanitizedDynamicSecretSchema, + data: z.unknown() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name: req.body.dynamicSecretName, + ...req.body + }); + return { lease, data, dynamicSecret }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index bf5cce7d5..b916bab67 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -23,7 +23,10 @@ const validateUsernameTemplateCharacters = characterValidator([ CharacterType.CloseBrace, CharacterType.CloseBracket, CharacterType.OpenBracket, - CharacterType.Fullstop + CharacterType.Fullstop, + CharacterType.SingleQuote, + CharacterType.Spaces, + CharacterType.Pipe ]); const userTemplateSchema = z @@ -33,7 +36,7 @@ const userTemplateSchema = z .refine((el) => validateUsernameTemplateCharacters(el)) .refine((el) => isValidHandleBarTemplate(el, { - allowedExpressions: (val) => ["randomUsername", "unixTimestamp"].includes(val) + allowedExpressions: (val) => ["randomUsername", "unixTimestamp", "identity.name"].includes(val) }) ); diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 0b8c78586..8f3b69dfa 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -6,6 +6,7 @@ import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; +import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerExternalKmsRouter } from "./external-kms-router"; import { registerGatewayRouter } from "./gateway-router"; @@ -18,6 +19,7 @@ import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOidcRouter } from "./oidc-router"; import { registerOrgRoleRouter } from "./org-role-router"; +import { registerPITRouter } from "./pit-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerRateLimitRouter } from "./rate-limit-router"; @@ -53,6 +55,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); await server.register(registerSnapshotRouter, { prefix: "/secret-snapshot" }); + await server.register(registerPITRouter, { prefix: "/pit" }); await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" }); await server.register(registerSecretApprovalRequestRouter, { prefix: "/secret-approval-requests" @@ -69,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { async (dynamicSecretRouter) => { await dynamicSecretRouter.register(registerDynamicSecretRouter); await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" }); + await dynamicSecretRouter.register(registerKubernetesDynamicSecretLeaseRouter, { prefix: "/leases/kubernetes" }); }, { prefix: "/dynamic-secrets" } ); diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts new file mode 100644 index 000000000..f993e31d7 --- /dev/null +++ b/backend/src/ee/routes/v1/pit-router.ts @@ -0,0 +1,416 @@ +/* eslint-disable @typescript-eslint/no-base-to-string */ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { booleanSchema } from "@app/server/routes/sanitizedSchemas"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas"; + +const commitHistoryItemSchema = z.object({ + id: z.string(), + folderId: z.string(), + actorType: z.string(), + actorMetadata: z.unknown().optional(), + message: z.string().optional().nullable(), + commitId: z.string(), + createdAt: z.string().or(z.date()), + envId: z.string() +}); + +const folderStateSchema = z.array( + z.object({ + type: z.string(), + id: z.string(), + versionId: z.string(), + secretKey: z.string().optional(), + secretVersion: z.number().optional(), + folderName: z.string().optional(), + folderVersion: z.number().optional() + }) +); + +export const registerPITRouter = async (server: FastifyZodProvider) => { + // Get commits count for a folder + server.route({ + method: "GET", + url: "/commits/count", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash), + projectId: z.string().trim() + }), + response: { + 200: z.object({ + count: z.number(), + folderId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.getCommitsCount({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + environment: req.query.environment, + path: req.query.path + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_PROJECT_PIT_COMMIT_COUNT, + metadata: { + environment: req.query.environment, + path: req.query.path, + commitCount: result.count.toString() + } + } + }); + + return result; + } + }); + + // Get all commits for a folder + server.route({ + method: "GET", + url: "/commits", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + environment: z.string().trim(), + path: z.string().trim().default("/").transform(removeTrailingSlash), + projectId: z.string().trim(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().trim().optional(), + sort: z.enum(["asc", "desc"]).default("desc") + }), + response: { + 200: z.object({ + commits: commitHistoryItemSchema.array(), + total: z.number(), + hasMore: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.getCommitsForFolder({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + environment: req.query.environment, + path: req.query.path, + offset: req.query.offset, + limit: req.query.limit, + search: req.query.search, + sort: req.query.sort + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_PROJECT_PIT_COMMITS, + metadata: { + environment: req.query.environment, + path: req.query.path, + commitCount: result.commits.length.toString(), + offset: req.query.offset.toString(), + limit: req.query.limit.toString(), + search: req.query.search, + sort: req.query.sort + } + } + }); + + return result; + } + }); + + // Get commit changes for a specific commit + server.route({ + method: "GET", + url: "/commits/:commitId/changes", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + commitId: z.string().trim() + }), + querystring: z.object({ + projectId: z.string().trim() + }), + response: { + 200: commitChangesResponseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.getCommitChanges({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + commitId: req.params.commitId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES, + metadata: { + commitId: req.params.commitId, + changesCount: (result.changes.changes?.length || 0).toString() + } + } + }); + + return result; + } + }); + + // Retrieve rollback changes for a commit + server.route({ + method: "GET", + url: "/commits/:commitId/compare", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + commitId: z.string().trim() + }), + querystring: z.object({ + folderId: z.string().trim(), + environment: z.string().trim(), + deepRollback: booleanSchema.default(false), + secretPath: z.string().trim().default("/").transform(removeTrailingSlash), + projectId: z.string().trim() + }), + response: { + 200: z.array( + z.object({ + folderId: z.string(), + folderName: z.string(), + folderPath: z.string().optional(), + changes: z.array(resourceChangeSchema) + }) + ) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.compareCommitChanges({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + commitId: req.params.commitId, + folderId: req.query.folderId, + environment: req.query.environment, + deepRollback: req.query.deepRollback, + secretPath: req.query.secretPath + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.PIT_COMPARE_FOLDER_STATES, + metadata: { + targetCommitId: req.params.commitId, + folderId: req.query.folderId, + deepRollback: req.query.deepRollback, + diffsCount: result.length.toString(), + environment: req.query.environment, + folderPath: req.query.secretPath + } + } + }); + + return result; + } + }); + + // Rollback to a previous commit + server.route({ + method: "POST", + url: "/commits/:commitId/rollback", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + commitId: z.string().trim() + }), + body: z.object({ + folderId: z.string().trim(), + deepRollback: z.boolean().default(false), + message: z.string().max(256).trim().optional(), + environment: z.string().trim(), + projectId: z.string().trim() + }), + response: { + 200: z.object({ + success: z.boolean(), + secretChangesCount: z.number().optional(), + folderChangesCount: z.number().optional(), + totalChanges: z.number().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.rollbackToCommit({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.body.projectId, + commitId: req.params.commitId, + folderId: req.body.folderId, + deepRollback: req.body.deepRollback, + message: req.body.message, + environment: req.body.environment + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.PIT_ROLLBACK_COMMIT, + metadata: { + targetCommitId: req.params.commitId, + environment: req.body.environment, + folderId: req.body.folderId, + deepRollback: req.body.deepRollback, + message: req.body.message || "Rollback to previous commit", + totalChanges: result.totalChanges?.toString() || "0" + } + } + }); + + return result; + } + }); + + // Revert commit + server.route({ + method: "POST", + url: "/commits/:commitId/revert", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + commitId: z.string().trim() + }), + body: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + success: z.boolean(), + message: z.string(), + originalCommitId: z.string(), + revertCommitId: z.string().optional(), + changesReverted: z.number().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.revertCommit({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.body.projectId, + commitId: req.params.commitId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.PIT_REVERT_COMMIT, + metadata: { + commitId: req.params.commitId, + revertCommitId: result.revertCommitId, + changesReverted: result.changesReverted?.toString() + } + } + }); + + return result; + } + }); + + // Folder state at commit + server.route({ + method: "GET", + url: "/commits/:commitId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + commitId: z.string().trim() + }), + querystring: z.object({ + folderId: z.string().trim(), + projectId: z.string().trim() + }), + response: { + 200: folderStateSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.pit.getFolderStateAtCommit({ + actor: req.permission?.type, + actorId: req.permission?.id, + actorOrgId: req.permission?.orgId, + actorAuthMethod: req.permission?.authMethod, + projectId: req.query.projectId, + commitId: req.params.commitId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.PIT_GET_FOLDER_STATE, + metadata: { + commitId: req.params.commitId, + folderId: req.query.folderId, + resourceCount: result.length.toString() + } + } + }); + + return result; + } + }); +}; diff --git a/backend/src/ee/routes/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index 3ee80adce..c14d97192 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -65,9 +65,10 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.Projects], - description: "Roll back project secrets to those captured in a secret snapshot version.", + description: "(Deprecated) Roll back project secrets to those captured in a secret snapshot version.", security: [ { bearerAuth: [] @@ -84,6 +85,10 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + throw new Error( + "This endpoint is deprecated. Please use the new PIT recovery system. More information is available at: https://infisical.com/docs/documentation/platform/pit-recovery." + ); + const secretSnapshot = await server.services.snapshot.rollbackSnapshot({ actor: req.permission.type, actorId: req.permission.id, 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 c33609621..5d4ccc021 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 @@ -6,6 +6,7 @@ import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-r import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rotation-router"; +import { registerOracleDBCredentialsRotationRouter } from "./oracledb-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; export * from "./secret-rotation-v2-router"; @@ -17,6 +18,7 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter, [SecretRotation.MySqlCredentials]: registerMySqlCredentialsRotationRouter, + [SecretRotation.OracleDBCredentials]: registerOracleDBCredentialsRotationRouter, [SecretRotation.Auth0ClientSecret]: registerAuth0ClientSecretRotationRouter, [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/oracledb-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/oracledb-credentials-rotation-router.ts new file mode 100644 index 000000000..b1721c59e --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/oracledb-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateOracleDBCredentialsRotationSchema, + OracleDBCredentialsRotationSchema, + UpdateOracleDBCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/oracledb-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 registerOracleDBCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.OracleDBCredentials, + server, + responseSchema: OracleDBCredentialsRotationSchema, + createSchema: CreateOracleDBCredentialsRotationSchema, + updateSchema: UpdateOracleDBCredentialsRotationSchema, + 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 5e3e09846..86768c3ad 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 @@ -7,6 +7,7 @@ import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/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 { OracleDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/oracledb-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"; @@ -18,6 +19,7 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ PostgresCredentialsRotationListItemSchema, MsSqlCredentialsRotationListItemSchema, MySqlCredentialsRotationListItemSchema, + OracleDBCredentialsRotationListItemSchema, Auth0ClientSecretRotationListItemSchema, AzureClientSecretRotationListItemSchema, AwsIamUserSecretRotationListItemSchema, 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 index 70cfd08dc..929a60df5 100644 --- 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 @@ -187,6 +187,56 @@ export const registerSecretScanningV2Router = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/findings", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update one or more Secret Scanning Findings in a batch.", + body: z + .object({ + findingId: z.string().trim().min(1, "Finding ID required").describe(SecretScanningFindings.UPDATE.findingId), + status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), + remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) + }) + .array() + .max(500), + response: { + 200: z.object({ findings: SecretScanningFindingSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { body, permission } = req; + + const updatedFindingPromises = body.map(async (findingUpdatePayload) => { + const { finding, projectId } = await server.services.secretScanningV2.updateSecretScanningFindingById( + findingUpdatePayload, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_UPDATE, + metadata: findingUpdatePayload + } + }); + + return finding; + }); + + const findings = await Promise.all(updatedFindingPromises); + + return { findings }; + } + }); + server.route({ method: "GET", url: "/configs", diff --git a/backend/src/ee/services/app-connections/oracledb/index.ts b/backend/src/ee/services/app-connections/oracledb/index.ts new file mode 100644 index 000000000..a9d29ab4b --- /dev/null +++ b/backend/src/ee/services/app-connections/oracledb/index.ts @@ -0,0 +1,4 @@ +export * from "./oracledb-connection-enums"; +export * from "./oracledb-connection-fns"; +export * from "./oracledb-connection-schemas"; +export * from "./oracledb-connection-types"; diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-enums.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-enums.ts new file mode 100644 index 000000000..570e56f2c --- /dev/null +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-enums.ts @@ -0,0 +1,3 @@ +export enum OracleDBConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-fns.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-fns.ts new file mode 100644 index 000000000..97ce64b11 --- /dev/null +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { OracleDBConnectionMethod } from "./oracledb-connection-enums"; + +export const getOracleDBConnectionListItem = () => { + return { + name: "OracleDB" as const, + app: AppConnection.OracleDB as const, + methods: Object.values(OracleDBConnectionMethod) as [OracleDBConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts new file mode 100644 index 000000000..f93abae83 --- /dev/null +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts @@ -0,0 +1,64 @@ +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 { BaseSqlUsernameAndPasswordConnectionSchema } from "@app/services/app-connection/shared/sql"; + +import { OracleDBConnectionMethod } from "./oracledb-connection-enums"; + +export const OracleDBConnectionCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BaseOracleDBConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.OracleDB) }); + +export const OracleDBConnectionSchema = BaseOracleDBConnectionSchema.extend({ + method: z.literal(OracleDBConnectionMethod.UsernameAndPassword), + credentials: OracleDBConnectionCredentialsSchema +}); + +export const SanitizedOracleDBConnectionSchema = z.discriminatedUnion("method", [ + BaseOracleDBConnectionSchema.extend({ + method: z.literal(OracleDBConnectionMethod.UsernameAndPassword), + credentials: OracleDBConnectionCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateOracleDBConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(OracleDBConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.OracleDB).method), + credentials: OracleDBConnectionCredentialsSchema.describe(AppConnections.CREATE(AppConnection.OracleDB).credentials) + }) +]); + +export const CreateOracleDBConnectionSchema = ValidateOracleDBConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true }) +); + +export const UpdateOracleDBConnectionSchema = z + .object({ + credentials: OracleDBConnectionCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.OracleDB).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OracleDB, { supportsPlatformManagedCredentials: true })); + +export const OracleDBConnectionListItemSchema = z.object({ + name: z.literal("OracleDB"), + app: z.literal(AppConnection.OracleDB), + methods: z.nativeEnum(OracleDBConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-types.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-types.ts new file mode 100644 index 000000000..cffed12da --- /dev/null +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-types.ts @@ -0,0 +1,17 @@ +import z from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + CreateOracleDBConnectionSchema, + OracleDBConnectionSchema, + ValidateOracleDBConnectionCredentialsSchema +} from "./oracledb-connection-schemas"; + +export type TOracleDBConnection = z.infer; + +export type TOracleDBConnectionInput = z.infer & { + app: AppConnection.OracleDB; +}; + +export type TValidateOracleDBConnectionCredentialsSchema = typeof ValidateOracleDBConnectionCredentialsSchema; 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 cfc01741f..87a98305f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -44,6 +44,7 @@ import { TSecretSyncRaw, TUpdateSecretSyncDTO } from "@app/services/secret-sync/secret-sync-types"; +import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; @@ -169,6 +170,12 @@ export enum EventType { REVOKE_IDENTITY_GCP_AUTH = "revoke-identity-gcp-auth", GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", + LOGIN_IDENTITY_ALICLOUD_AUTH = "login-identity-alicloud-auth", + ADD_IDENTITY_ALICLOUD_AUTH = "add-identity-alicloud-auth", + UPDATE_IDENTITY_ALICLOUD_AUTH = "update-identity-alicloud-auth", + REVOKE_IDENTITY_ALICLOUD_AUTH = "revoke-identity-alicloud-auth", + GET_IDENTITY_ALICLOUD_AUTH = "get-identity-alicloud-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", @@ -206,6 +213,7 @@ export enum EventType { CREATE_WEBHOOK = "create-webhook", UPDATE_WEBHOOK_STATUS = "update-webhook-status", DELETE_WEBHOOK = "delete-webhook", + WEBHOOK_TRIGGERED = "webhook-triggered", GET_SECRET_IMPORTS = "get-secret-imports", GET_SECRET_IMPORT = "get-secret-import", CREATE_SECRET_IMPORT = "create-secret-import", @@ -393,6 +401,13 @@ export enum EventType { PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end", + GET_PROJECT_PIT_COMMITS = "get-project-pit-commits", + GET_PROJECT_PIT_COMMIT_CHANGES = "get-project-pit-commit-changes", + GET_PROJECT_PIT_COMMIT_COUNT = "get-project-pit-commit-count", + PIT_ROLLBACK_COMMIT = "pit-rollback-commit", + 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", @@ -1051,6 +1066,53 @@ interface GetIdentityAwsAuthEvent { }; } +interface LoginIdentityAliCloudAuthEvent { + type: EventType.LOGIN_IDENTITY_ALICLOUD_AUTH; + metadata: { + identityId: string; + identityAliCloudAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAliCloudAuthEvent { + type: EventType.ADD_IDENTITY_ALICLOUD_AUTH; + metadata: { + identityId: string; + allowedArns: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface DeleteIdentityAliCloudAuthEvent { + type: EventType.REVOKE_IDENTITY_ALICLOUD_AUTH; + metadata: { + identityId: string; + }; +} + +interface UpdateIdentityAliCloudAuthEvent { + type: EventType.UPDATE_IDENTITY_ALICLOUD_AUTH; + metadata: { + identityId: string; + allowedArns: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAliCloudAuthEvent { + type: EventType.GET_IDENTITY_ALICLOUD_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOciAuthEvent { type: EventType.LOGIN_IDENTITY_OCI_AUTH; metadata: { @@ -1440,6 +1502,14 @@ interface DeleteWebhookEvent { }; } +export interface WebhookTriggeredEvent { + type: EventType.WEBHOOK_TRIGGERED; + metadata: { + webhookId: string; + status: string; + } & TWebhookPayloads; +} + interface GetSecretImportsEvent { type: EventType.GET_SECRET_IMPORTS; metadata: { @@ -2979,6 +3049,78 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent { }; } +interface GetProjectPitCommitsEvent { + type: EventType.GET_PROJECT_PIT_COMMITS; + metadata: { + commitCount: string; + environment: string; + path: string; + offset: string; + limit: string; + search?: string; + sort: string; + }; +} + +interface GetProjectPitCommitChangesEvent { + type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES; + metadata: { + changesCount: string; + commitId: string; + }; +} + +interface GetProjectPitCommitCountEvent { + type: EventType.GET_PROJECT_PIT_COMMIT_COUNT; + metadata: { + environment: string; + path: string; + commitCount: string; + }; +} + +interface PitRollbackCommitEvent { + type: EventType.PIT_ROLLBACK_COMMIT; + metadata: { + targetCommitId: string; + folderId: string; + deepRollback: boolean; + message: string; + totalChanges: string; + environment: string; + }; +} + +interface PitRevertCommitEvent { + type: EventType.PIT_REVERT_COMMIT; + metadata: { + commitId: string; + revertCommitId?: string; + changesReverted?: string; + }; +} + +interface PitGetFolderStateEvent { + type: EventType.PIT_GET_FOLDER_STATE; + metadata: { + commitId: string; + folderId: string; + resourceCount: string; + }; +} + +interface PitCompareFolderStatesEvent { + type: EventType.PIT_COMPARE_FOLDER_STATES; + metadata: { + targetCommitId: string; + folderId: string; + deepRollback: boolean; + diffsCount: string; + environment: string; + folderPath: string; + }; +} + interface SecretScanningDataSourceListEvent { type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; metadata: { @@ -3183,6 +3325,11 @@ export type Event = | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent | DeleteIdentityAwsAuthEvent + | LoginIdentityAliCloudAuthEvent + | AddIdentityAliCloudAuthEvent + | UpdateIdentityAliCloudAuthEvent + | GetIdentityAliCloudAuthEvent + | DeleteIdentityAliCloudAuthEvent | LoginIdentityOciAuthEvent | AddIdentityOciAuthEvent | UpdateIdentityOciAuthEvent @@ -3221,6 +3368,7 @@ export type Event = | CreateWebhookEvent | UpdateWebhookStatusEvent | DeleteWebhookEvent + | WebhookTriggeredEvent | GetSecretImportsEvent | GetSecretImportEvent | CreateSecretImportEvent @@ -3397,6 +3545,13 @@ export type Event = | MicrosoftTeamsWorkflowIntegrationGetEvent | MicrosoftTeamsWorkflowIntegrationListEvent | MicrosoftTeamsWorkflowIntegrationUpdateEvent + | GetProjectPitCommitsEvent + | GetProjectPitCommitChangesEvent + | PitRollbackCommitEvent + | GetProjectPitCommitCountEvent + | PitRevertCommitEvent + | PitCompareFolderStatesEvent + | PitGetFolderStateEvent | SecretScanningDataSourceListEvent | SecretScanningDataSourceGetEvent | SecretScanningDataSourceCreateEvent diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index fa1a80ac3..497e94311 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -10,6 +10,7 @@ import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types"; type TDynamicSecretLeaseQueueServiceFactoryDep = { queueService: TQueueServiceFactory; @@ -99,7 +100,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; - await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { + projectId: folder.projectId + }); await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); return; } @@ -132,8 +135,15 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); await Promise.all( - dynamicSecretLeases.map(({ externalEntityId }) => - selectedProvider.revoke(decryptedStoredInput, externalEntityId) + dynamicSecretLeases.map(({ externalEntityId, config }) => + selectedProvider.revoke( + decryptedStoredInput, + externalEntityId, + { + projectId: folder.projectId + }, + config as TDynamicSecretLeaseConfig + ) ) ); } 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 f3f3f3acd..106f72334 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 @@ -1,4 +1,5 @@ import { ForbiddenError, subject } from "@casl/ability"; +import RE2 from "re2"; import { ActionProjectType } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -11,10 +12,13 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; @@ -25,6 +29,7 @@ import { TCreateDynamicSecretLeaseDTO, TDeleteDynamicSecretLeaseDTO, TDetailsDynamicSecretLeaseDTO, + TDynamicSecretLeaseConfig, TListDynamicSecretLeasesDTO, TRenewDynamicSecretLeaseDTO } from "./dynamic-secret-lease-types"; @@ -39,6 +44,8 @@ type TDynamicSecretLeaseServiceFactoryDep = { permissionService: Pick; projectDAL: Pick; kmsService: Pick; + userDAL: Pick; + identityDAL: TIdentityDALFactory; }; export type TDynamicSecretLeaseServiceFactory = ReturnType; @@ -52,8 +59,16 @@ export const dynamicSecretLeaseServiceFactory = ({ dynamicSecretQueueService, projectDAL, licenseService, - kmsService + kmsService, + userDAL, + identityDAL }: TDynamicSecretLeaseServiceFactoryDep) => { + const extractEmailUsername = (email: string) => { + const regex = new RE2(/^([^@]+)/); + const match = email.match(regex); + return match ? match[1] : email; + }; + const create = async ({ environmentSlug, path, @@ -63,7 +78,8 @@ export const dynamicSecretLeaseServiceFactory = ({ actorId, actorOrgId, actorAuthMethod, - ttl + ttl, + config }: TCreateDynamicSecretLeaseDTO) => { const appCfg = getConfig(); const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); @@ -132,10 +148,25 @@ export const dynamicSecretLeaseServiceFactory = ({ let result; try { + const identity: { name: string } = { name: "" }; + if (actor === ActorType.USER) { + const user = await userDAL.findById(actorId); + if (user) { + identity.name = extractEmailUsername(user.username); + } + } else if (actor === ActorType.Machine) { + const machineIdentity = await identityDAL.findById(actorId); + if (machineIdentity) { + identity.name = machineIdentity.name; + } + } result = await selectedProvider.create({ inputs: decryptedStoredInput, expireAt: expireAt.getTime(), - usernameTemplate: dynamicSecretCfg.usernameTemplate + usernameTemplate: dynamicSecretCfg.usernameTemplate, + identity, + metadata: { projectId }, + config }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { @@ -149,8 +180,10 @@ export const dynamicSecretLeaseServiceFactory = ({ expireAt, version: 1, dynamicSecretId: dynamicSecretCfg.id, - externalEntityId: entityId + externalEntityId: entityId, + config }); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; }; @@ -231,13 +264,17 @@ export const dynamicSecretLeaseServiceFactory = ({ const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL)); if (maxTTL) { const maxExpiryDate = new Date(dynamicSecretLease.createdAt.getTime() + ms(maxTTL)); - if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max ttl" }); + if (expireAt > maxExpiryDate) + throw new BadRequestError({ + message: "The requested renewal would exceed the maximum allowed lease duration. Please choose a shorter TTL" + }); } const { entityId } = await selectedProvider.renew( decryptedStoredInput, dynamicSecretLease.externalEntityId, - expireAt.getTime() + expireAt.getTime(), + { projectId } ); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); @@ -313,7 +350,12 @@ export const dynamicSecretLeaseServiceFactory = ({ ) as object; const revokeResponse = await selectedProvider - .revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId) + .revoke( + decryptedStoredInput, + dynamicSecretLease.externalEntityId, + { projectId }, + dynamicSecretLease.config as TDynamicSecretLeaseConfig + ) .catch(async (err) => { // only propogate this error if forced is false if (!isForced) return { error: err as Error }; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts index bf182b349..f6d9f6297 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -10,6 +10,7 @@ export type TCreateDynamicSecretLeaseDTO = { environmentSlug: string; ttl?: string; projectSlug: string; + config?: TDynamicSecretLeaseConfig; } & Omit; export type TDetailsDynamicSecretLeaseDTO = { @@ -41,3 +42,9 @@ export type TRenewDynamicSecretLeaseDTO = { ttl?: string; projectSlug: string; } & Omit; + +export type TDynamicSecretKubernetesLeaseConfig = { + namespace?: string; +}; + +export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig; 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 16ac10716..b502bf9f3 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -116,7 +116,7 @@ export const dynamicSecretServiceFactory = ({ throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); const selectedProvider = dynamicSecretProviders[provider.type]; - const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId }); let selectedGatewayId: string | null = null; if (inputs && typeof inputs === "object" && "gatewayId" in inputs && inputs.gatewayId) { @@ -146,7 +146,7 @@ export const dynamicSecretServiceFactory = ({ selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(provider.inputs); + const isConnected = await selectedProvider.validateConnection(provider.inputs, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ @@ -272,7 +272,7 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const newInput = { ...decryptedStoredInput, ...(inputs || {}) }; - const updatedInput = await selectedProvider.validateProviderInputs(newInput); + const updatedInput = await selectedProvider.validateProviderInputs(newInput, { projectId }); let selectedGatewayId: string | null = null; if (updatedInput && typeof updatedInput === "object" && "gatewayId" in updatedInput && updatedInput?.gatewayId) { @@ -301,7 +301,7 @@ export const dynamicSecretServiceFactory = ({ selectedGatewayId = gateway.id; } - const isConnected = await selectedProvider.validateConnection(newInput); + const isConnected = await selectedProvider.validateConnection(newInput, { projectId }); if (!isConnected) throw new BadRequestError({ message: "Provider connection failed" }); const updatedDynamicCfg = await dynamicSecretDAL.transaction(async (tx) => { @@ -472,7 +472,9 @@ export const dynamicSecretServiceFactory = ({ secretManagerDecryptor({ cipherTextBlob: dynamicSecretCfg.encryptedInput }).toString() ) as object; const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; - const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput)) as object; + const providerInputs = (await selectedProvider.validateProviderInputs(decryptedStoredInput, { + projectId + })) as object; return { ...dynamicSecretCfg, inputs: providerInputs }; }; 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 56fa110d1..89371f1bd 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -16,6 +16,7 @@ import { BadRequestError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const CreateElastiCacheUserSchema = z.object({ UserId: z.string().trim().min(1), @@ -132,14 +133,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; const randomUsername = `inf-${customAlphabet(charset, 32)()}`; if (!usernameTemplate) return randomUsername; - - return handlebars.compile(usernameTemplate)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -174,14 +175,21 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { + name: string; + }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); if (!(await validateConnection(providerInputs))) { throw new BadRequestError({ message: "Failed to establish connection" }); } - const leaseUsername = generateUsername(usernameTemplate); + const leaseUsername = generateUsername(usernameTemplate, identity); 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 9d8e10f60..f7383d4ac 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,21 +16,25 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; -import handlebars from "handlebars"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { randomUUID } from "crypto"; import { z } from "zod"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const randomUsername = alphaNumericNanoId(32); if (!usernameTemplate) return randomUsername; - return handlebars.compile(usernameTemplate)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -40,7 +44,43 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return providerInputs; }; - const $getClient = async (providerInputs: z.infer) => { + const $getClient = async (providerInputs: z.infer, projectId: string) => { + const appCfg = getConfig(); + if (providerInputs.method === AwsIamAuthType.AssumeRole) { + const stsClient = new STSClient({ + region: providerInputs.region, + credentials: + appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY + } + : undefined // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: providerInputs.roleArn, + RoleSessionName: `infisical-dynamic-secret-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: projectId + }); + + const assumeRes = await stsClient.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: assumeRes.Credentials?.AccessKeyId, + secretAccessKey: assumeRes.Credentials?.SecretAccessKey, + sessionToken: assumeRes.Credentials?.SessionToken + } + }); + return client; + } + const client = new IAMClient({ region: providerInputs.region, credentials: { @@ -52,21 +92,41 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return client; }; - const validateConnection = async (inputs: unknown) => { + const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); - - const isConnected = await client.send(new GetUserCommand({})).then(() => true); + const client = await $getClient(providerInputs, projectId); + const isConnected = await client + .send(new GetUserCommand({})) + .then(() => true) + .catch((err) => { + const message = (err as Error)?.message; + if ( + providerInputs.method === AwsIamAuthType.AssumeRole && + // assume role will throw an error asking to provider username, but if so this has access in aws correctly + message.includes("Must specify userName when calling with non-User credentials") + ) { + return true; + } + throw err; + }); return isConnected; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { + name: string; + }; + metadata: { projectId: string }; + }) => { + const { inputs, usernameTemplate, metadata, identity } = data; const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const client = await $getClient(providerInputs, metadata.projectId); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; const createUserRes = await client.send( new CreateUserCommand({ @@ -76,6 +136,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => { UserName: username }) ); + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); if (userGroups) { await Promise.all( @@ -125,9 +186,9 @@ export const AwsIamProvider = (): TDynamicProviderFns => { }; }; - const revoke = async (inputs: unknown, entityId: string) => { + const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => { const providerInputs = await validateProviderInputs(inputs); - const client = await $getClient(providerInputs); + const client = await $getClient(providerInputs, metadata.projectId); const username = entityId; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index fce23b56f..b939dcad6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -8,19 +8,20 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -75,12 +76,17 @@ export const CassandraProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 066822827..32d21ee76 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -1,5 +1,4 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; -import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -7,19 +6,20 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -71,12 +71,12 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); await connection.security.putUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts b/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts new file mode 100644 index 000000000..b5d34aa49 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/gcp-iam.ts @@ -0,0 +1,105 @@ +import { gaxios, Impersonated, JWT } from "google-auth-library"; +import { GetAccessTokenResponse } from "google-auth-library/build/src/auth/oauth2client"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretGcpIamSchema, TDynamicProviderFns } from "./models"; + +export const GcpIamProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretGcpIamSchema.parseAsync(inputs); + return providerInputs; + }; + + const $getToken = async (serviceAccountEmail: string, ttl: number): Promise => { + const appCfg = getConfig(); + if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) { + throw new InternalServerError({ + message: "Environment variable has not been configured: INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL" + }); + } + + const credJson = JSON.parse(appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) as { + client_email: string; + private_key: string; + }; + + const sourceClient = new JWT({ + email: credJson.client_email, + key: credJson.private_key, + scopes: ["https://www.googleapis.com/auth/cloud-platform"] + }); + + const impersonatedCredentials = new Impersonated({ + sourceClient, + targetPrincipal: serviceAccountEmail, + lifetime: ttl, + delegates: [], + targetScopes: ["https://www.googleapis.com/auth/iam", "https://www.googleapis.com/auth/cloud-platform"] + }); + + let tokenResponse: GetAccessTokenResponse | undefined; + try { + tokenResponse = await impersonatedCredentials.getAccessToken(); + } catch (error) { + let message = "Unable to validate connection"; + if (error instanceof gaxios.GaxiosError) { + message = error.message; + } + + throw new BadRequestError({ + message + }); + } + + if (!tokenResponse || !tokenResponse.token) { + throw new BadRequestError({ + message: "Unable to validate connection" + }); + } + + return tokenResponse.token; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + await $getToken(providerInputs.serviceAccountEmail, 10); + return true; + }; + + const create = async (data: { inputs: unknown; expireAt: number }) => { + const { inputs, expireAt } = data; + + const providerInputs = await validateProviderInputs(inputs); + + const now = Math.floor(Date.now() / 1000); + const ttl = Math.max(Math.floor(expireAt / 1000) - now, 0); + + const token = await $getToken(providerInputs.serviceAccountEmail, ttl); + const entityId = alphaNumericNanoId(32); + + return { entityId, data: { SERVICE_ACCOUNT_EMAIL: providerInputs.serviceAccountEmail, TOKEN: token } }; + }; + + const revoke = async (_inputs: unknown, entityId: string) => { + // There's no way to revoke GCP IAM access tokens + return { entityId }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + // To renew a token it must be re-created + const data = await create({ inputs, expireAt }); + + return { ...data, entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 76ef7ef2a..7e14cf1ab 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 { GcpIamProvider } from "./gcp-iam"; import { KubernetesProvider } from "./kubernetes"; import { LdapProvider } from "./ldap"; import { DynamicSecretProviders, TDynamicProviderFns } from "./models"; @@ -42,5 +43,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), - [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }) + [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), + [DynamicSecretProviders.GcpIam]: GcpIamProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 8a54ba089..45cc06e7c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -1,24 +1,46 @@ -import axios from "axios"; +import axios, { AxiosError } from "axios"; +import handlebars from "handlebars"; import https from "https"; -import { InternalServerError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; +import { TDynamicSecretKubernetesLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; -import { DynamicSecretKubernetesSchema, TDynamicProviderFns } from "./models"; +import { + DynamicSecretKubernetesSchema, + KubernetesAuthMethod, + KubernetesCredentialType, + KubernetesRoleType, + TDynamicProviderFns +} from "./models"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; +// This value is just a placeholder. When using gateway auth method, the url is irrelevant. +const GATEWAY_AUTH_DEFAULT_URL = "https://kubernetes.default.svc.cluster.local"; + type TKubernetesProviderDTO = { gatewayService: Pick; }; +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `dynamic-secret-sa-${alphaNumericNanoId(10).toLowerCase()}`; + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs); - if (!providerInputs.gatewayId) { + if (!providerInputs.gatewayId && providerInputs.url) { await blockLocalAndPrivateIpAddresses(providerInputs.url); } @@ -30,19 +52,27 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): gatewayId: string; targetHost: string; targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; + enableSsl: 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) => { + async (port, httpsAgent) => { // 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); + 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, @@ -53,7 +83,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): 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: inputs.enableSsl + }) } ); @@ -63,7 +98,189 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): const validateConnection = async (inputs: unknown) => { const providerInputs = await validateProviderInputs(inputs); - const serviceAccountGetCallback = async (host: string, port: number) => { + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(); + const roleBindingName = `${serviceAccountName}-role-binding`; + + const namespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim()); + + // Test each namespace sequentially instead of in parallel to simplify cleanup + for await (const namespace of namespaces) { + try { + // 1. Create a test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 2. Create a test role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 3. Request a token for the test service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: 600, // 10 minutes + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 4. Cleanup: delete role binding and service account + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } + + await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } catch (error) { + const cleanupInfo = `You may need to manually clean up the following resources in namespace "${namespace}": Service Account - ${serviceAccountName}, ${providerInputs.roleType === KubernetesRoleType.Role ? "Role" : "Cluster Role"} Binding - ${roleBindingName}.`; + let mainErrorMessage = "Unknown error"; + if (error instanceof AxiosError) { + mainErrorMessage = (error.response?.data as { message: string })?.message; + } else if (error instanceof Error) { + mainErrorMessage = error.message; + } + + throw new Error(`${mainErrorMessage}. ${cleanupInfo}`); + } + } + }; + + const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + const baseUrl = port ? `${host}:${port}` : host; await axios.get( @@ -71,36 +288,63 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${providerInputs.clusterToken}` + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent: new https.Agent({ - ca: providerInputs.ca, - rejectUnauthorized: providerInputs.sslEnabled - }) + timeout: EXTERNAL_REQUEST_TIMEOUT } ); }; - const url = new URL(providerInputs.url); + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || ""; + const url = new URL(rawUrl); + const k8sGatewayHost = url.hostname; const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; try { if (providerInputs.gatewayId) { - const k8sHost = url.hostname; - - await $gatewayProxyWrapper( - { - gatewayId: providerInputs.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort - }, - serviceAccountGetCallback - ); + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? serviceAccountStaticCallback + : serviceAccountDynamicCallback + ); + } + } else if (providerInputs.credentialType === KubernetesCredentialType.Static) { + await serviceAccountStaticCallback(k8sHost, k8sPort); } else { - const k8sHost = `${url.protocol}//${url.hostname}`; - await serviceAccountGetCallback(k8sHost, k8sPort); + await serviceAccountDynamicCallback(k8sHost, k8sPort); } return true; @@ -116,10 +360,153 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } }; - const create = async ({ inputs, expireAt }: { inputs: unknown; expireAt: number }) => { + const create = async ({ + inputs, + expireAt, + usernameTemplate, + config + }: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + config?: TDynamicSecretKubernetesLeaseConfig; + }) => { const providerInputs = await validateProviderInputs(inputs); - const tokenRequestCallback = async (host: string, port: number) => { + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const serviceAccountName = generateUsername(usernameTemplate); + const roleBindingName = `${serviceAccountName}-role-binding`; + const allowedNamespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim()); + + if (config?.namespace && !allowedNamespaces?.includes(config?.namespace)) { + throw new BadRequestError({ + message: `Namespace ${config?.namespace} is not allowed. Allowed namespaces: ${allowedNamespaces?.join(", ")}` + }); + } + + const namespace = config?.namespace || allowedNamespaces[0]; + if (!namespace) { + throw new BadRequestError({ + message: "No namespace provided" + }); + } + + // 1. Create the service account + await axios.post( + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`, + { + metadata: { + name: serviceAccountName, + namespace + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 2. Create the role binding + const roleBindingUrl = + providerInputs.roleType === KubernetesRoleType.ClusterRole + ? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings` + : `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`; + + const roleBindingMetadata = { + name: roleBindingName, + ...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace }) + }; + + await axios.post( + roleBindingUrl, + { + metadata: roleBindingMetadata, + roleRef: { + kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role", + name: providerInputs.role, + apiGroup: "rbac.authorization.k8s.io" + }, + subjects: [ + { + kind: "ServiceAccount", + name: serviceAccountName, + namespace + } + ] + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + // 3. Request a token for the service account + const res = await axios.post( + `${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`, + { + spec: { + expirationSeconds: Math.floor((expireAt - Date.now()) / 1000), + ...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + return { ...res.data, serviceAccountName }; + }; + + const tokenRequestStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Static) { + throw new Error("invalid callback"); + } + + if (config?.namespace && config.namespace !== providerInputs.namespace) { + throw new BadRequestError({ + message: `Namespace ${config?.namespace} is not allowed. Allowed namespace: ${providerInputs.namespace}.` + }); + } + const baseUrl = port ? `${host}:${port}` : host; const res = await axios.post( @@ -133,39 +520,71 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${providerInputs.clusterToken}` + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), - timeout: EXTERNAL_REQUEST_TIMEOUT, - httpsAgent: new https.Agent({ - ca: providerInputs.ca, - rejectUnauthorized: providerInputs.sslEnabled - }) + timeout: EXTERNAL_REQUEST_TIMEOUT } ); - return res.data; + return { ...res.data, serviceAccountName: providerInputs.serviceAccountName }; }; - const url = new URL(providerInputs.url); + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || ""; + const url = new URL(rawUrl); 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( + let tokenData; + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + tokenData = await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } else { + tokenData = await $gatewayProxyWrapper( { gatewayId: providerInputs.gatewayId, targetHost: k8sGatewayHost, - targetPort: k8sPort + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false }, - tokenRequestCallback - ) - : await tokenRequestCallback(k8sHost, k8sPort); + providerInputs.credentialType === KubernetesCredentialType.Static + ? tokenRequestStaticCallback + : serviceAccountDynamicCallback + ); + } + } else { + tokenData = + providerInputs.credentialType === KubernetesCredentialType.Static + ? await tokenRequestStaticCallback(k8sHost, k8sPort) + : await serviceAccountDynamicCallback(k8sHost, k8sPort); + } return { - entityId: providerInputs.serviceAccountName, + entityId: tokenData.serviceAccountName, data: { TOKEN: tokenData.status.token } }; } catch (error) { @@ -180,7 +599,122 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): } }; - const revoke = async (_inputs: unknown, entityId: string) => { + const revoke = async ( + inputs: unknown, + entityId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _metadata: { projectId: string }, + config?: TDynamicSecretKubernetesLeaseConfig + ) => { + const providerInputs = await validateProviderInputs(inputs); + + const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => { + if (providerInputs.credentialType !== KubernetesCredentialType.Dynamic) { + throw new Error("invalid callback"); + } + + const baseUrl = port ? `${host}:${port}` : host; + const roleBindingName = `${entityId}-role-binding`; + + const namespace = config?.namespace ?? providerInputs.namespace.split(",")[0].trim(); + + if (providerInputs.roleType === KubernetesRoleType.Role) { + await axios.delete( + `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`, + { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + } else { + await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } + + // Delete the service account + await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${entityId}`, { + headers: { + "Content-Type": "application/json", + ...(providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount } + : { Authorization: `Bearer ${providerInputs.clusterToken}` }) + }, + ...(providerInputs.authMethod === KubernetesAuthMethod.Api + ? { + httpsAgent + } + : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + }; + + if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) { + const rawUrl = + providerInputs.authMethod === KubernetesAuthMethod.Gateway + ? GATEWAY_AUTH_DEFAULT_URL + : providerInputs.url || ""; + + const url = new URL(rawUrl); + const k8sGatewayHost = url.hostname; + const k8sPort = url.port ? Number(url.port) : 443; + const k8sHost = `${url.protocol}//${url.hostname}`; + + if (providerInputs.gatewayId) { + if (providerInputs.authMethod === KubernetesAuthMethod.Gateway) { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: true + }, + serviceAccountDynamicCallback + ); + } else { + await $gatewayProxyWrapper( + { + gatewayId: providerInputs.gatewayId, + targetHost: k8sGatewayHost, + targetPort: k8sPort, + enableSsl: providerInputs.sslEnabled, + caCert: providerInputs.ca, + reviewTokenThroughGateway: false + }, + serviceAccountDynamicCallback + ); + } + } else { + await serviceAccountDynamicCallback(k8sHost, k8sPort); + } + } + return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index d0e3fbe66..1de8aa1e6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -9,6 +9,7 @@ import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { LdapCredentialType, LdapSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; @@ -22,13 +23,13 @@ const encodePassword = (password?: string) => { return base64Password; }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -196,8 +197,8 @@ export const LdapProvider = (): TDynamicProviderFns => { return dnArray; }; - const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); @@ -224,7 +225,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }); } } else { - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 91d26da32..8f361e166 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,10 @@ +import RE2 from "re2"; import { z } from "zod"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; + +import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types"; + export type PasswordRequirements = { length: number; required: { @@ -20,6 +25,11 @@ export enum SqlProviders { Vertica = "vertica" } +export enum AwsIamAuthType { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + export enum ElasticSearchAuthTypes { User = "user", ApiKey = "api-key" @@ -31,7 +41,18 @@ export enum LdapCredentialType { } export enum KubernetesCredentialType { - Static = "static" + Static = "static", + Dynamic = "dynamic" +} + +export enum KubernetesRoleType { + ClusterRole = "cluster-role", + Role = "role" +} + +export enum KubernetesAuthMethod { + Gateway = "gateway", + Api = "api" } export enum TotpConfigType { @@ -168,16 +189,38 @@ export const DynamicSecretSapAseSchema = z.object({ revocationStatement: z.string().trim() }); -export const DynamicSecretAwsIamSchema = z.object({ - accessKey: z.string().trim().min(1), - secretAccessKey: z.string().trim().min(1), - region: z.string().trim().min(1), - awsPath: z.string().trim().optional(), - permissionBoundaryPolicyArn: z.string().trim().optional(), - policyDocument: z.string().trim().optional(), - userGroups: z.string().trim().optional(), - policyArns: z.string().trim().optional() -}); +export const DynamicSecretAwsIamSchema = z.preprocess( + (val) => { + if (typeof val === "object" && val !== null && !Object.hasOwn(val, "method")) { + // eslint-disable-next-line no-param-reassign + (val as { method: string }).method = AwsIamAuthType.AccessKey; + } + return val; + }, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsIamAuthType.AccessKey), + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() + }), + z.object({ + method: z.literal(AwsIamAuthType.AssumeRole), + roleArn: z.string().trim().min(1, "Role ARN required"), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() + }) + ]) +); export const DynamicSecretMongoAtlasSchema = z.object({ adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"), @@ -282,17 +325,89 @@ 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 DynamicSecretKubernetesSchema = z + .discriminatedUnion("credentialType", [ + z.object({ + url: z + .string() + .optional() + .refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), { + message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)" + }), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.Static), + serviceAccountName: z.string().trim().min(1), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => !val.includes(","), "Namespace must be a single value, not a comma-separated list") + .refine( + (val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), + "Invalid namespace format" + ), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }), + z.object({ + url: z + .string() + .url() + .optional() + .refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), { + message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)" + }), + clusterToken: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().default(false), + credentialType: z.literal(KubernetesCredentialType.Dynamic), + namespace: z + .string() + .trim() + .min(1) + .refine((val) => { + const namespaces = val.split(",").map((ns) => ns.trim()); + return ( + namespaces.length > 0 && + namespaces.every((ns) => ns.length > 0) && + namespaces.every((ns) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(ns)) + ); + }, "Must be a valid comma-separated list of namespace values"), + gatewayId: z.string().optional(), + audiences: z.array(z.string().trim().min(1)), + roleType: z.nativeEnum(KubernetesRoleType), + role: z.string().trim().min(1), + authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api) + }) + ]) + .superRefine((data, ctx) => { + if (data.authMethod === KubernetesAuthMethod.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Gateway, a gateway must be selected" + }); + } + if (data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) { + if (!data.clusterToken) { + ctx.addIssue({ + path: ["clusterToken"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster token must be provided" + }); + } + if (!data.url) { + ctx.addIssue({ + path: ["url"], + code: z.ZodIssueCode.custom, + message: "When auth method is set to Token, a cluster URL must be provided" + }); + } + } + }); export const DynamicSecretVerticaSchema = z.object({ host: z.string().trim().toLowerCase(), @@ -355,6 +470,10 @@ export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ }) ]); +export const DynamicSecretGcpIamSchema = z.object({ + serviceAccountEmail: z.string().email().trim().min(1, "Service account email required").max(128) +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -372,7 +491,8 @@ export enum DynamicSecretProviders { Totp = "totp", SapAse = "sap-ase", Kubernetes = "kubernetes", - Vertica = "vertica" + Vertica = "vertica", + GcpIam = "gcp-iam" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -392,7 +512,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), 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 }) + z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), + z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }) ]); export type TDynamicProviderFns = { @@ -400,9 +521,24 @@ export type TDynamicProviderFns = { inputs: unknown; expireAt: number; usernameTemplate?: string | null; + identity?: { + name: string; + }; + metadata: { projectId: string }; + config?: TDynamicSecretLeaseConfig; }) => Promise<{ entityId: string; data: unknown }>; - validateConnection: (inputs: unknown) => Promise; - validateProviderInputs: (inputs: object) => Promise; - revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; - renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; + validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise; + validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise; + revoke: ( + inputs: unknown, + entityId: string, + metadata: { projectId: string }, + config?: TDynamicSecretLeaseConfig + ) => Promise<{ entityId: string }>; + renew: ( + inputs: unknown, + entityId: string, + expireAt: number, + metadata: { projectId: 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 8f8bf9430..6da8b4b4e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -1,5 +1,4 @@ import axios, { AxiosError } from "axios"; -import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -7,19 +6,20 @@ import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const randomUsername = alphaNumericNanoId(32); if (!usernameTemplate) return randomUsername; - - return handlebars.compile(usernameTemplate)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -64,12 +64,17 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 0a15209e0..331a355a7 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -1,4 +1,3 @@ -import handlebars from "handlebars"; import { MongoClient } from "mongodb"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -7,19 +6,20 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { const randomUsername = alphaNumericNanoId(32); if (!usernameTemplate) return randomUsername; - - return handlebars.compile(usernameTemplate)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -60,12 +60,12 @@ export const MongoDBProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 e7d90d272..76081c86c 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -1,5 +1,4 @@ import axios, { Axios } from "axios"; -import handlebars from "handlebars"; import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -9,19 +8,20 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -117,12 +117,12 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 855af2e29..989ed96dc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -9,19 +9,20 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; return customAlphabet(charset, 64)(); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -121,12 +122,17 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return pingResponse; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 af2431058..9c13d3efc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -9,19 +9,20 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -87,11 +88,11 @@ export const SapAseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { - const { inputs, usernameTemplate } = data; + const create = async (data: { inputs: unknown; usernameTemplate?: string | null; identity?: { name: string } }) => { + const { inputs, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); const password = generatePassword(); const client = await $getClient(providerInputs); 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 654e2d144..5c8a75555 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -15,19 +15,20 @@ import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const generatePassword = (size = 48) => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -97,11 +98,16 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return testResult; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 571d488c9..9e97ecd30 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -8,6 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; // destroy client requires callback... const noop = () => {}; @@ -17,13 +18,13 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = (usernameTemplate?: string | null) => { +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { 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)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity }); }; @@ -88,13 +89,18 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return isValidConnection; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(usernameTemplate); + const username = generateUsername(usernameTemplate, identity); 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 ce16a1237..d3217be37 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,13 +3,14 @@ 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"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -104,9 +105,8 @@ const generatePassword = (provider: SqlProviders, requirements?: PasswordRequire } }; -const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null) => { +const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null, identity?: { name: string }) => { let randomUsername = ""; - // For oracle, the client assumes everything is upper case when not using quotes around the password if (provider === SqlProviders.Oracle) { randomUsername = alphaNumericNanoId(32).toUpperCase(); @@ -114,10 +114,13 @@ const generateUsername = (provider: SqlProviders, usernameTemplate?: string | nu randomUsername = alphaNumericNanoId(32); } if (!usernameTemplate) return randomUsername; - - return handlebars.compile(usernameTemplate)({ + return compileUsernameTemplate({ + usernameTemplate, randomUsername, - unixTimestamp: Math.floor(Date.now() / 100) + identity, + options: { + toUpperCase: provider === SqlProviders.Oracle + } }); }; @@ -185,6 +188,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await gatewayCallback("localhost", port); }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: providerInputs.host, targetPort: providerInputs.port, relayHost, @@ -220,11 +224,16 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return isConnected; }; - const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { - const { inputs, expireAt, usernameTemplate } = data; + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(providerInputs.client, usernameTemplate); + const username = generateUsername(providerInputs.client, usernameTemplate, identity); const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { diff --git a/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts b/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts new file mode 100644 index 000000000..70a083dbf --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/templateUtils.ts @@ -0,0 +1,80 @@ +/* eslint-disable func-names */ +import handlebars from "handlebars"; +import RE2 from "re2"; + +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +export const compileUsernameTemplate = ({ + usernameTemplate, + randomUsername, + identity, + unixTimestamp, + options +}: { + usernameTemplate: string; + randomUsername: string; + identity?: { name: string }; + unixTimestamp?: number; + options?: { + toUpperCase?: boolean; + }; +}): string => { + // Create isolated handlebars instance + const hbs = handlebars.create(); + + // Register random helper on local instance + hbs.registerHelper("random", function (length: number) { + if (typeof length !== "number" || length <= 0 || length > 100) { + return ""; + } + return alphaNumericNanoId(length); + }); + + // Register replace helper on local instance + hbs.registerHelper("replace", function (text: string, searchValue: string, replaceValue: string) { + // Convert to string if it's not already + const textStr = String(text || ""); + if (!textStr) { + return textStr; + } + + try { + const re2Pattern = new RE2(searchValue, "g"); + // Replace all occurrences + return re2Pattern.replace(textStr, replaceValue); + } catch (error) { + logger.error(error, "RE2 pattern failed, using original template"); + return textStr; + } + }); + + // Register truncate helper on local instance + hbs.registerHelper("truncate", function (text: string, length: number) { + // Convert to string if it's not already + const textStr = String(text || ""); + if (!textStr) { + return textStr; + } + + if (typeof length !== "number" || length <= 0) return textStr; + return textStr.substring(0, length); + }); + + // Compile template with context using local instance + const context = { + randomUsername, + unixTimestamp: unixTimestamp || Math.floor(Date.now() / 100), + identity: { + name: identity?.name + } + }; + + const result = hbs.compile(usernameTemplate)(context); + + if (options?.toUpperCase) { + return result.toUpperCase(); + } + + return result; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts index 9e283ab41..e361ab329 100644 --- a/backend/src/ee/services/dynamic-secret/providers/vertica.ts +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -4,7 +4,7 @@ import knex, { Knex } from "knex"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +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"; @@ -196,6 +196,7 @@ export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynam await gatewayCallback("localhost", port); }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: providerInputs.host, targetPort: providerInputs.port, relayHost, diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 9424075ca..1d7c5fc71 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -42,6 +42,10 @@ export type TListGroupUsersDTO = { filter?: EFilterReturnedUsers; } & TGenericPermission; +export type TListProjectGroupUsersDTO = TListGroupUsersDTO & { + projectId: string; +}; + export type TAddUserToGroupDTO = { id: string; username: string; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e2cf09bb1..80e58815e 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -709,6 +709,10 @@ export const licenseServiceFactory = ({ return licenses; }; + const invalidateGetPlan = async (orgId: string) => { + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -723,6 +727,7 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, getOrgPlan, diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 40c5310ae..cca4efaf2 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -4,6 +4,7 @@ import { ProjectPermissionActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, + ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, ProjectPermissionIdentityActions, @@ -90,6 +91,11 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates ); + can( + [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], + ProjectPermissionSub.Commits + ); + can( [ ProjectPermissionSshHostActions.Edit, @@ -292,6 +298,11 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretImports ); + can( + [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], + ProjectPermissionSub.Commits + ); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); @@ -479,6 +490,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); can( [ diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 34cffcd98..f61c4b1a4 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -17,6 +17,11 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionCommitsActions { + Read = "read", + PerformRollback = "perform-rollback" +} + export enum ProjectPermissionCertificateActions { Read = "read", Create = "create", @@ -172,6 +177,7 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", + Commits = "commits", Identity = "identity", CertificateAuthorities = "certificate-authorities", Certificates = "certificates", @@ -325,6 +331,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] + | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; @@ -376,7 +383,8 @@ const DynamicSecretConditionV2Schema = z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }) .partial() ]), @@ -404,6 +412,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretImportConditionSchema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretConditionV2Schema = z .object({ environment: z.union([ @@ -658,6 +683,12 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.Commits).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCommitsActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z .literal(ProjectPermissionSub.SecretScanningDataSources) @@ -741,7 +772,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ), - conditions: SecretConditionV1Schema.describe( + conditions: SecretImportConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() }), diff --git a/backend/src/ee/services/pit/pit-service.ts b/backend/src/ee/services/pit/pit-service.ts new file mode 100644 index 000000000..160729123 --- /dev/null +++ b/backend/src/ee/services/pit/pit-service.ts @@ -0,0 +1,485 @@ +/* eslint-disable no-await-in-loop */ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { + isFolderCommitChange, + isSecretCommitChange +} from "@app/services/folder-commit-changes/folder-commit-changes-dal"; +import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; +import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; + +type TPitServiceFactoryDep = { + folderCommitService: TFolderCommitServiceFactory; + secretService: Pick; + folderService: Pick; + permissionService: Pick; + folderDAL: Pick; + projectEnvDAL: Pick; +}; + +export type TPitServiceFactory = ReturnType; + +export const pitServiceFactory = ({ + folderCommitService, + secretService, + folderService, + permissionService, + folderDAL, + projectEnvDAL +}: TPitServiceFactoryDep) => { + const getCommitsCount = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + environment: string; + path: string; + }) => { + const result = await folderCommitService.getCommitsCount({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path + }); + + return result; + }; + + const getCommitsForFolder = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path, + offset, + limit, + search, + sort + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + environment: string; + path: string; + offset: number; + limit: number; + search?: string; + sort: "asc" | "desc"; + }) => { + const result = await folderCommitService.getCommitsForFolder({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + environment, + path, + offset, + limit, + search, + sort + }); + + return { + commits: result.commits.map((commit) => ({ + ...commit, + commitId: commit.commitId.toString() + })), + total: result.total, + hasMore: result.hasMore + }; + }; + + const getCommitChanges = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const changes = await folderCommitService.getCommitChanges({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }); + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [changes.folderId]); + + for (const change of changes.changes) { + if (isSecretCommitChange(change)) { + change.versions = await secretService.getChangeVersions( + { + secretVersion: change.secretVersion, + secretId: change.secretId, + id: change.id, + isUpdate: change.isUpdate, + changeType: change.changeType + }, + (Number.parseInt(change.secretVersion, 10) - 1).toString(), + actorId, + actor, + actorOrgId, + actorAuthMethod, + changes.envId, + projectId, + folderWithPath?.path || "" + ); + } else if (isFolderCommitChange(change)) { + change.versions = await folderService.getFolderVersions( + change, + (Number.parseInt(change.folderVersion, 10) - 1).toString(), + change.folderChangeId + ); + } + } + + return { + changes: { + ...changes, + commitId: changes.commitId.toString() + } + }; + }; + + const compareCommitChanges = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId, + folderId, + environment, + deepRollback, + secretPath + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + folderId: string; + environment: string; + deepRollback: boolean; + secretPath: string; + }) => { + const latestCommit = await folderCommitService.getLatestCommit({ + folderId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + const targetCommit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + const env = await projectEnvDAL.findOne({ + projectId, + slug: environment + }); + + if (!latestCommit) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + let diffs; + if (deepRollback) { + diffs = await folderCommitService.deepCompareFolder({ + targetCommitId: targetCommit.id, + envId: env.id, + projectId + }); + } else { + const folderData = await folderService.getFolderById({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + id: folderId + }); + + diffs = [ + { + folderId: folderData.id, + folderName: folderData.name, + folderPath: secretPath, + changes: await folderCommitService.compareFolderStates({ + targetCommitId: commitId, + currentCommitId: latestCommit.id + }) + } + ]; + } + + for (const diff of diffs) { + for (const change of diff.changes) { + // Use discriminated union type checking + if (change.type === ResourceType.SECRET) { + // TypeScript now knows this is a SecretChange + if (change.secretKey && change.secretVersion && change.secretId) { + change.versions = await secretService.getChangeVersions( + { + secretVersion: change.secretVersion, + secretId: change.secretId, + id: change.id, + isUpdate: change.isUpdate, + changeType: change.changeType + }, + change.fromVersion || "1", + actorId, + actor, + actorOrgId, + actorAuthMethod, + env.id, + projectId, + diff.folderPath || "" + ); + } + } else if (change.type === ResourceType.FOLDER) { + // TypeScript now knows this is a FolderChange + if (change.folderVersion) { + change.versions = await folderService.getFolderVersions(change, change.fromVersion || "1", change.id); + } + } + } + } + + return diffs; + }; + + const rollbackToCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId, + folderId, + deepRollback, + message, + environment + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + folderId: string; + deepRollback: boolean; + message?: string; + environment: string; + }) => { + const { permission: userPermission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(userPermission).throwUnlessCan( + ProjectPermissionCommitsActions.PerformRollback, + ProjectPermissionSub.Commits + ); + + const latestCommit = await folderCommitService.getLatestCommit({ + folderId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + if (!latestCommit) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + logger.info(`PIT - Attempting to rollback folder ${folderId} from commit ${latestCommit.id} to commit ${commitId}`); + + const targetCommit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + + const env = await projectEnvDAL.findOne({ + projectId, + slug: environment + }); + + if (!targetCommit || targetCommit.folderId !== folderId || targetCommit.envId !== env.id) { + throw new NotFoundError({ message: "Target commit not found" }); + } + + if (!latestCommit || latestCommit.envId !== env.id) { + throw new NotFoundError({ message: "Latest commit not found" }); + } + + if (deepRollback) { + await folderCommitService.deepRollbackFolder(commitId, env.id, actorId, actor, projectId, message); + return { success: true }; + } + + const diff = await folderCommitService.compareFolderStates({ + currentCommitId: latestCommit.id, + targetCommitId: commitId + }); + + const response = await folderCommitService.applyFolderStateDifferences({ + differences: diff, + actorInfo: { + actorType: actor, + actorId, + message: message || "Rollback to previous commit" + }, + folderId, + projectId, + reconstructNewFolders: deepRollback + }); + + return { + success: true, + secretChangesCount: response.secretChangesCount, + folderChangesCount: response.folderChangesCount, + totalChanges: response.totalChanges + }; + }; + + const revertCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const response = await folderCommitService.revertCommitChanges({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + + return response; + }; + + const getFolderStateAtCommit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorOrgId: string; + actorAuthMethod: ActorAuthMethod; + projectId: string; + commitId: string; + }) => { + const commit = await folderCommitService.getCommitById({ + commitId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }); + + if (!commit) { + throw new NotFoundError({ message: `Commit with ID ${commitId} not found` }); + } + + const response = await folderCommitService.reconstructFolderState(commitId); + + return response.map((item) => { + if (item.type === ResourceType.SECRET) { + return { + ...item, + secretVersion: Number(item.secretVersion) + }; + } + + if (item.type === ResourceType.FOLDER) { + return { + ...item, + folderVersion: Number(item.folderVersion) + }; + } + + return item; + }); + }; + + return { + getCommitsCount, + getCommitsForFolder, + getCommitChanges, + compareCommitChanges, + rollbackToCommit, + revertCommit, + getFolderStateAtCommit + }; +}; 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 217181281..9f1ee5307 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 @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import { ForbiddenError, subject } from "@casl/ability"; import { @@ -20,6 +21,7 @@ import { EnforcementLevel } from "@app/lib/types"; import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; @@ -130,6 +132,7 @@ type TSecretApprovalRequestServiceFactoryDep = { licenseService: Pick; projectMicrosoftTeamsConfigDAL: Pick; microsoftTeamsService: Pick; + folderCommitService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -161,7 +164,8 @@ export const secretApprovalRequestServiceFactory = ({ projectSlackConfigDAL, resourceMetadataDAL, projectMicrosoftTeamsConfigDAL, - microsoftTeamsService + microsoftTeamsService, + folderCommitService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -243,7 +247,7 @@ export const secretApprovalRequestServiceFactory = ({ const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { policy } = secretApprovalRequest; - const { hasRole } = await permissionService.getProjectPermission({ + const { hasRole, permission } = await permissionService.getProjectPermission({ actor, actorId, projectId, @@ -259,6 +263,12 @@ export const secretApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } + const hasSecretReadAccess = permission.can( + ProjectPermissionSecretActions.DescribeAndReadValue, + ProjectPermissionSub.Secrets + ); + const hiddenSecretValue = "******"; + let secrets; if (shouldUseSecretV2Bridge) { const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -275,9 +285,9 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValue: - // eslint-disable-next-line no-nested-ternary - el.secret && el.secret.isRotatedSecret + secretValue: !hasSecretReadAccess + ? hiddenSecretValue + : el.secret && el.secret.isRotatedSecret ? undefined : el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() @@ -290,9 +300,11 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secret.key, id: el.secret.id, version: el.secret.version, - secretValue: el.secret.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() - : "", + secretValue: !hasSecretReadAccess + ? hiddenSecretValue + : el.secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() + : "", secretComment: el.secret.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedComment }).toString() : "" @@ -303,9 +315,11 @@ export const secretApprovalRequestServiceFactory = ({ secretKey: el.secretVersion.key, id: el.secretVersion.id, version: el.secretVersion.version, - secretValue: el.secretVersion.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() - : "", + secretValue: !hasSecretReadAccess + ? hiddenSecretValue + : el.secretVersion.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() + : "", secretComment: el.secretVersion.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString() : "", @@ -597,6 +611,10 @@ export const secretApprovalRequestServiceFactory = ({ ? await fnSecretV2BridgeBulkInsert({ tx, folderId, + actor: { + actorId, + type: actor + }, orgId: actorOrgId, inputSecrets: secretCreationCommits.map((el) => ({ tagIds: el?.tags.map(({ id }) => id), @@ -619,13 +637,18 @@ export const secretApprovalRequestServiceFactory = ({ secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretTagDAL, - secretVersionTagDAL: secretVersionTagV2BridgeDAL + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + folderCommitService }) : []; const updatedSecrets = secretUpdationCommits.length ? await fnSecretV2BridgeBulkUpdate({ folderId, orgId: actorOrgId, + actor: { + actorId, + type: actor + }, tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = @@ -659,7 +682,8 @@ export const secretApprovalRequestServiceFactory = ({ secretVersionDAL: secretVersionV2BridgeDAL, secretTagDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }) : []; const deletedSecret = secretDeletionCommits.length @@ -667,10 +691,13 @@ export const secretApprovalRequestServiceFactory = ({ projectId, folderId, tx, - actorId: "", + actorId, + actorType: actor, secretDAL: secretV2BridgeDAL, secretQueueService, - inputSecrets: secretDeletionCommits.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })) + inputSecrets: secretDeletionCommits.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })), + folderCommitService, + secretVersionDAL: secretVersionV2BridgeDAL }) : []; const updatedSecretApproval = await secretApprovalRequestDAL.updateById( diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts index 90fdf561e..628f8e310 100644 --- a/backend/src/ee/services/secret-replication/secret-replication-service.ts +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -10,6 +10,7 @@ import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { QueueName, TQueueServiceFactory } from "@app/queue"; import { ActorType } from "@app/services/auth/auth-type"; +import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -87,6 +88,7 @@ type TSecretReplicationServiceFactoryDep = { projectBotService: Pick; kmsService: Pick; + folderCommitService: Pick; }; export type TSecretReplicationServiceFactory = ReturnType; @@ -132,6 +134,7 @@ export const secretReplicationServiceFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, kmsService, + folderCommitService, resourceMetadataDAL }: TSecretReplicationServiceFactoryDep) => { const $getReplicatedSecrets = ( @@ -419,7 +422,7 @@ export const secretReplicationServiceFactory = ({ return { op: operation, requestId: approvalRequestDoc.id, - metadata: doc.metadata, + metadata: doc.metadata ? JSON.stringify(doc.metadata) : [], secretMetadata: JSON.stringify(doc.secretMetadata), key: doc.key, encryptedValue: doc.encryptedValue, @@ -446,11 +449,12 @@ export const secretReplicationServiceFactory = ({ tx, secretTagDAL, resourceMetadataDAL, + folderCommitService, secretVersionTagDAL: secretVersionV2TagBridgeDAL, inputSecrets: locallyCreatedSecrets.map((doc) => { return { type: doc.type, - metadata: doc.metadata, + metadata: doc.metadata ? JSON.stringify(doc.metadata) : [], key: doc.key, encryptedValue: doc.encryptedValue, encryptedComment: doc.encryptedComment, @@ -466,6 +470,7 @@ export const secretReplicationServiceFactory = ({ orgId, folderId: destinationReplicationFolderId, secretVersionDAL: secretVersionV2BridgeDAL, + folderCommitService, secretDAL: secretV2BridgeDAL, tx, resourceMetadataDAL, @@ -479,7 +484,7 @@ export const secretReplicationServiceFactory = ({ }, data: { type: doc.type, - metadata: doc.metadata, + metadata: doc.metadata ? JSON.stringify(doc.metadata) : [], key: doc.key, encryptedValue: doc.encryptedValue as Buffer, encryptedComment: doc.encryptedComment, diff --git a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts index 037df50ac..e2969ac1d 100644 --- a/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/azure-client-secret/azure-client-secret-rotation-fns.ts @@ -101,10 +101,56 @@ export const azureClientSecretRotationFactory: TRotationFactory< } }; + /** + * Checks if a credential with the given keyId exists. + */ + const credentialExists = async (keyId: string): Promise => { + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); + const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/passwordCredentials`; + + try { + const { data } = await request.get<{ value: Array<{ keyId: string }> }>(endpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + }); + + return data.value?.some((credential) => credential.keyId === keyId) || false; + } catch (error: unknown) { + if (error instanceof AxiosError) { + let message; + if ( + error.response?.data && + typeof error.response.data === "object" && + "error" in error.response.data && + typeof (error.response.data as AzureErrorResponse).error.message === "string" + ) { + message = (error.response.data as AzureErrorResponse).error.message; + } + throw new BadRequestError({ + message: `Failed to check credential existence for app ${objectId}: ${ + message || error.message || "Unknown error" + }` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + /** * Revokes a client secret from the Azure app using its keyId. + * First checks if the credential exists before attempting revocation. */ const revokeCredential = async (keyId: string) => { + // Check if credential exists before attempting revocation + const exists = await credentialExists(keyId); + if (!exists) { + return; // Credential doesn't exist, nothing to revoke + } + const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService); const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`; diff --git a/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/index.ts new file mode 100644 index 000000000..2902d780d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./oracledb-credentials-rotation-constants"; +export * from "./oracledb-credentials-rotation-schemas"; +export * from "./oracledb-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-constants.ts new file mode 100644 index 000000000..dd685041c --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-constants.ts @@ -0,0 +1,20 @@ +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 ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "OracleDB Credentials", + type: SecretRotation.OracleDBCredentials, + connection: AppConnection.OracleDB, + template: { + createUserStatement: `-- create user +CREATE USER INFISICAL_USER IDENTIFIED BY "temporary_password"; + +-- grant all privileges +GRANT ALL PRIVILEGES TO INFISICAL_USER;`, + secretsMapping: { + username: "ORACLEDB_USERNAME", + password: "ORACLEDB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-schemas.ts new file mode 100644 index 000000000..267098e9c --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-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 OracleDBCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.OracleDBCredentials).extend({ + type: z.literal(SecretRotation.OracleDBCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreateOracleDBCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.OracleDBCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdateOracleDBCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.OracleDBCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const OracleDBCredentialsRotationListItemSchema = z.object({ + name: z.literal("OracleDB Credentials"), + connection: z.literal(AppConnection.OracleDB), + type: z.literal(SecretRotation.OracleDBCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-types.ts new file mode 100644 index 000000000..d303eea62 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/oracledb-credentials/oracledb-credentials-rotation-types.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { TOracleDBConnection } from "../../app-connections/oracledb"; +import { + CreateOracleDBCredentialsRotationSchema, + OracleDBCredentialsRotationListItemSchema, + OracleDBCredentialsRotationSchema +} from "./oracledb-credentials-rotation-schemas"; + +export type TOracleDBCredentialsRotation = z.infer; + +export type TOracleDBCredentialsRotationInput = z.infer; + +export type TOracleDBCredentialsRotationListItem = z.infer; + +export type TOracleDBCredentialsRotationWithConnection = TOracleDBCredentialsRotation & { + connection: TOracleDBConnection; +}; 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 a8c92e255..84dc30821 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 @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", MySqlCredentials = "mysql-credentials", + OracleDBCredentials = "oracledb-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 ea1b99107..228c4c2a1 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 @@ -10,6 +10,7 @@ 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 { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-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"; @@ -25,6 +26,7 @@ const SECRET_ROTATION_LIST_OPTIONS: Record = { [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", [SecretRotation.MsSqlCredentials]: "Microsoft SQL Server Credentials", [SecretRotation.MySqlCredentials]: "MySQL Credentials", + [SecretRotation.OracleDBCredentials]: "OracleDB Credentials", [SecretRotation.Auth0ClientSecret]: "Auth0 Client Secret", [SecretRotation.AzureClientSecret]: "Azure Client Secret", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", @@ -15,6 +16,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record; - secretVersionV2BridgeDAL: Pick; + secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; secretTagDAL: Pick; @@ -106,6 +107,7 @@ export type TSecretRotationV2ServiceFactoryDep = { snapshotService: Pick; queueService: Pick; appConnectionDAL: Pick; + folderCommitService: Pick; }; export type TSecretRotationV2ServiceFactory = ReturnType; @@ -121,6 +123,7 @@ const SECRET_ROTATION_FACTORY_MAP: Record { const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { @@ -538,7 +542,12 @@ export const secretRotationV2ServiceFactory = ({ secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, secretTagDAL, - resourceMetadataDAL + folderCommitService, + resourceMetadataDAL, + actor: { + type: actor.type, + actorId: actor.id + } }); await secretRotationV2DAL.insertSecretMappings( @@ -674,7 +683,12 @@ export const secretRotationV2ServiceFactory = ({ secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, secretTagDAL, - resourceMetadataDAL + folderCommitService, + resourceMetadataDAL, + actor: { + type: actor.type, + actorId: actor.id + } }); secretsMappingUpdated = true; @@ -792,6 +806,9 @@ export const secretRotationV2ServiceFactory = ({ projectId, folderId, actorId: actor.id, // not actually used since rotated secrets are shared + actorType: actor.type, + folderCommitService, + secretVersionDAL: secretVersionV2BridgeDAL, tx }); } @@ -935,6 +952,10 @@ export const secretRotationV2ServiceFactory = ({ secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, + folderCommitService, + actor: { + type: ActorType.PLATFORM + }, secretTagDAL, resourceMetadataDAL }); 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 3fe42a983..5547d4582 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 @@ -45,6 +45,12 @@ import { TMySqlCredentialsRotationListItem, TMySqlCredentialsRotationWithConnection } from "./mysql-credentials"; +import { + TOracleDBCredentialsRotation, + TOracleDBCredentialsRotationInput, + TOracleDBCredentialsRotationListItem, + TOracleDBCredentialsRotationWithConnection +} from "./oracledb-credentials"; import { TPostgresCredentialsRotation, TPostgresCredentialsRotationInput, @@ -58,6 +64,7 @@ export type TSecretRotationV2 = | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TMySqlCredentialsRotation + | TOracleDBCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation | TLdapPasswordRotation @@ -67,6 +74,7 @@ export type TSecretRotationV2WithConnection = | TPostgresCredentialsRotationWithConnection | TMsSqlCredentialsRotationWithConnection | TMySqlCredentialsRotationWithConnection + | TOracleDBCredentialsRotationWithConnection | TAuth0ClientSecretRotationWithConnection | TAzureClientSecretRotationWithConnection | TLdapPasswordRotationWithConnection @@ -83,6 +91,7 @@ export type TSecretRotationV2Input = | TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput | TMySqlCredentialsRotationInput + | TOracleDBCredentialsRotationInput | TAuth0ClientSecretRotationInput | TAzureClientSecretRotationInput | TLdapPasswordRotationInput @@ -92,6 +101,7 @@ export type TSecretRotationV2ListItem = | TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem | TMySqlCredentialsRotationListItem + | TOracleDBCredentialsRotationListItem | 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 cbbf44e7e..6dd04d47e 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 @@ -1,18 +1,19 @@ import { z } from "zod"; import { Auth0ClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/auth0-client-secret"; +import { AwsIamUserSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; 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 { OracleDBCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials"; import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; -import { AwsIamUserSecretRotationSchema } from "./aws-iam-user-secret"; - export const SecretRotationV2Schema = z.discriminatedUnion("type", [ PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, MySqlCredentialsRotationSchema, + OracleDBCredentialsRotationSchema, 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 ab06074d7..ae357bfea 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 @@ -2,14 +2,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 { TOracleDBCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/oracledb-credentials"; import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; - -import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas"; export type TSqlCredentialsRotationWithConnection = | TPostgresCredentialsRotationWithConnection | TMsSqlCredentialsRotationWithConnection - | TMySqlCredentialsRotationWithConnection; + | TMySqlCredentialsRotationWithConnection + | TOracleDBCredentialsRotationWithConnection; 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 dd2b5a5ea..e204bbef9 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 @@ -178,6 +178,13 @@ export const getDbSetQuery = (db: TDbProviderClients, variables: { username: str }; } + if (db === TDbProviderClients.OracleDB) { + 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-rotation/secret-rotation-queue/secret-rotation-queue.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts index 2c6124348..d792ac6e6 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue.ts @@ -14,6 +14,7 @@ import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { ActorType } from "@app/services/auth/auth-type"; +import { CommitType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -53,6 +54,7 @@ type TSecretRotationQueueFactoryDep = { secretVersionV2BridgeDAL: Pick; telemetryService: Pick; kmsService: Pick; + folderCommitService: Pick; }; // These error should stop the repeatable job and ask user to reconfigure rotation @@ -77,6 +79,7 @@ export const secretRotationQueueFactory = ({ telemetryService, secretV2BridgeDAL, secretVersionV2BridgeDAL, + folderCommitService, kmsService }: TSecretRotationQueueFactoryDep) => { const addToQueue = async (rotationId: string, interval: number) => { @@ -330,7 +333,7 @@ export const secretRotationQueueFactory = ({ })), tx ); - await secretVersionV2BridgeDAL.insertMany( + const secretVersions = await secretVersionV2BridgeDAL.insertMany( updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({ ...el, actorType: ActorType.PLATFORM, @@ -338,6 +341,22 @@ export const secretRotationQueueFactory = ({ })), tx ); + + await folderCommitService.createCommit( + { + actor: { + type: ActorType.PLATFORM + }, + message: "Changed by Secret rotation", + folderId: secretVersions[0].folderId, + changes: secretVersions.map((sv) => ({ + type: CommitType.ADD, + isUpdate: true, + secretVersionId: sv.id + })) + }, + tx + ); }); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId); diff --git a/backend/src/ee/services/secret-rotation/templates/types.ts b/backend/src/ee/services/secret-rotation/templates/types.ts index 2ec998db7..e2ad8384f 100644 --- a/backend/src/ee/services/secret-rotation/templates/types.ts +++ b/backend/src/ee/services/secret-rotation/templates/types.ts @@ -10,7 +10,8 @@ export enum TDbProviderClients { // mysql and maria db MySql = "mysql", - MsSqlServer = "mssql" + MsSqlServer = "mssql", + OracleDB = "oracledb" } export enum TAwsProviderSystems { diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index 015a8d420..8cae3dfb5 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -8,6 +8,7 @@ import { InternalServerError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "@app/services/auth/auth-type"; +import { CommitType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -51,8 +52,8 @@ type TSecretSnapshotServiceFactoryDep = { snapshotSecretV2BridgeDAL: TSnapshotSecretV2DALFactory; snapshotFolderDAL: TSnapshotFolderDALFactory; secretVersionDAL: Pick; - secretVersionV2BridgeDAL: Pick; - folderVersionDAL: Pick; + secretVersionV2BridgeDAL: Pick; + folderVersionDAL: Pick; secretDAL: Pick; secretV2BridgeDAL: Pick; secretTagDAL: Pick; @@ -63,6 +64,7 @@ type TSecretSnapshotServiceFactoryDep = { licenseService: Pick; kmsService: Pick; projectBotService: Pick; + folderCommitService: Pick; }; export type TSecretSnapshotServiceFactory = ReturnType; @@ -84,7 +86,8 @@ export const secretSnapshotServiceFactory = ({ snapshotSecretV2BridgeDAL, secretVersionV2TagBridgeDAL, kmsService, - projectBotService + projectBotService, + folderCommitService }: TSecretSnapshotServiceFactoryDep) => { const projectSecretSnapshotCount = async ({ environment, @@ -403,6 +406,18 @@ export const secretSnapshotServiceFactory = ({ .filter((el) => el.isRotatedSecret) .map((el) => el.secretId); + const deletedSecretsChanges = new Map(); // secretId -> version info + const deletedFoldersChanges = new Map(); // folderId -> version info + const addedSecretsChanges = new Map(); // secretId -> version info + const addedFoldersChanges = new Map(); // folderId -> version info + const commitChanges: { + type: string; + secretVersionId?: string; + folderVersionId?: string; + isUpdate?: boolean; + folderId?: string; + }[] = []; + // this will remove all secrets in current folder except rotated secrets which we ignore const deletedTopLevelSecs = await secretV2BridgeDAL.delete( { @@ -424,7 +439,35 @@ export const secretSnapshotServiceFactory = ({ }, tx ); + + await Promise.all( + deletedTopLevelSecs.map(async (sec) => { + const version = await secretVersionV2BridgeDAL.findOne({ secretId: sec.id, version: sec.version }, tx); + deletedSecretsChanges.set(sec.id, { + id: sec.id, + version: sec.version, + // Store the version ID if available from the snapshot + versionId: version?.id + }); + }) + ); + const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); + + const deletedFoldersData = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx); + + await Promise.all( + deletedFoldersData.map(async (folder) => { + const version = await folderVersionDAL.findOne({ folderId: folder.id, version: folder.version }, tx); + deletedFoldersChanges.set(folder.id, { + id: folder.id, + version: folder.version, + // Store the version ID if available + versionId: version?.id + }); + }) + ); + // this will remove all secrets and folders on child // due to sql foreign key and link list connection removing the folders removes everything below too const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx); @@ -489,14 +532,21 @@ export const secretSnapshotServiceFactory = ({ }); await secretTagDAL.saveTagsToSecretV2(secretTagsToBeInsert, tx); const folderVersions = await folderVersionDAL.insertMany( - folders.map(({ version, name, id, envId }) => ({ + folders.map(({ version, name, id, envId, description }) => ({ name, version, folderId: id, - envId + envId, + description })), tx ); + + // Track added folders + folderVersions.forEach((fv) => { + addedFoldersChanges.set(fv.folderId, fv); + }); + const userActorId = actor === ActorType.USER ? actorId : undefined; const identityActorId = actor !== ActorType.USER ? actorId : undefined; const actorType = actor || ActorType.PLATFORM; @@ -511,6 +561,11 @@ export const secretSnapshotServiceFactory = ({ })), tx ); + + secretVersions.forEach((sv) => { + addedSecretsChanges.set(sv.secretId, sv); + }); + await secretVersionV2TagBridgeDAL.insertMany( secretVersions.flatMap(({ secretId, id }) => secretVerTagToBeInsert?.[secretId]?.length @@ -522,6 +577,70 @@ export const secretSnapshotServiceFactory = ({ ), tx ); + + // Compute commit changes + // Handle secrets + deletedSecretsChanges.forEach((deletedInfo, secretId) => { + const addedSecret = addedSecretsChanges.get(secretId); + if (addedSecret) { + // Secret was deleted and re-added - this is an update only if versions are different + if (deletedInfo.versionId !== addedSecret.id) { + commitChanges.push({ + type: CommitType.ADD, // In the commit system, updates are tracked as "add" with isUpdate=true + secretVersionId: addedSecret.id, + isUpdate: true + }); + } + // Remove from addedSecrets since we've handled it + addedSecretsChanges.delete(secretId); + } else if (deletedInfo.versionId) { + // Secret was only deleted + commitChanges.push({ + type: CommitType.DELETE, + secretVersionId: deletedInfo.versionId + }); + } + }); + // Add remaining new secrets (not updates) + addedSecretsChanges.forEach((addedSecret) => { + commitChanges.push({ + type: CommitType.ADD, + secretVersionId: addedSecret.id + }); + }); + + // Handle folders + deletedFoldersChanges.forEach((deletedInfo, folderId) => { + const addedFolder = addedFoldersChanges.get(folderId); + if (addedFolder) { + // Folder was deleted and re-added - this is an update only if versions are different + if (deletedInfo.versionId !== addedFolder.id) { + commitChanges.push({ + type: CommitType.ADD, + folderVersionId: addedFolder.id, + isUpdate: true + }); + } + // Remove from addedFolders since we've handled it + addedFoldersChanges.delete(folderId); + } else if (deletedInfo.versionId) { + // Folder was only deleted + commitChanges.push({ + type: CommitType.DELETE, + folderVersionId: deletedInfo.versionId, + folderId: deletedInfo.id + }); + } + }); + + // Add remaining new folders (not updates) + addedFoldersChanges.forEach((addedFolder) => { + commitChanges.push({ + type: CommitType.ADD, + folderVersionId: addedFolder.id + }); + }); + const newSnapshot = await snapshotDAL.create( { folderId: snapshot.folderId, @@ -550,6 +669,22 @@ export const secretSnapshotServiceFactory = ({ })), tx ); + if (commitChanges.length > 0) { + await folderCommitService.createCommit( + { + actor: { + type: actorType, + metadata: { + id: userActorId || identityActorId + } + }, + message: "Rollback to snapshot", + folderId: snapshot.folderId, + changes: commitChanges + }, + tx + ); + } return { ...newSnapshot, snapshotSecrets, snapshotFolders }; }); @@ -609,11 +744,12 @@ export const secretSnapshotServiceFactory = ({ }); await secretTagDAL.saveTagsToSecret(secretTagsToBeInsert, tx); const folderVersions = await folderVersionDAL.insertMany( - folders.map(({ version, name, id, envId }) => ({ + folders.map(({ version, name, id, envId, description }) => ({ name, version, folderId: id, - envId + envId, + description })), tx ); 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 a7eec1078..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; @@ -26,6 +27,7 @@ export const KeyStorePrefixes = { KmsOrgDataKeyCreation: "kms-org-data-key-creation-lock", WaitUntilReadyKmsOrgKeyCreation: "wait-until-ready-kms-org-key-creation-", WaitUntilReadyKmsOrgDataKeyCreation: "wait-until-ready-kms-org-data-key-creation-", + FolderTreeCheckpoint: (envId: string) => `folder-tree-checkpoint-${envId}`, WaitUntilReadyProjectEnvironmentOperation: (projectId: string) => `wait-until-ready-project-environments-operation-${projectId}`, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71e284ae6..1f5f5a4ea 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -21,6 +21,7 @@ export enum ApiDocsTags { TokenAuth = "Token Auth", UniversalAuth = "Universal Auth", GcpAuth = "GCP Auth", + AliCloudAuth = "Alibaba Cloud Auth", AwsAuth = "AWS Auth", OciAuth = "OCI Auth", AzureAuth = "Azure Auth", @@ -89,6 +90,7 @@ export const GROUPS = { limit: "The number of users to return.", username: "The username to search for.", search: "The text string that user email or name will be filtered by.", + projectId: "The ID of the project the group belongs to.", filterUsers: "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, @@ -242,6 +244,43 @@ export const LDAP_AUTH = { } } as const; +export const ALICLOUD_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login.", + Action: "The Alibaba Cloud API action. For STS GetCallerIdentity, this should be 'GetCallerIdentity'.", + Format: "The response format. For STS GetCallerIdentity, this should be 'JSON'.", + Version: "The API version. This should be in 'YYYY-MM-DD' format (e.g., '2015-04-01').", + AccessKeyId: "The AccessKey ID of the RAM user or STS token.", + SignatureMethod: "The signature algorithm. For STS GetCallerIdentity, this should be 'HMAC-SHA1'.", + Timestamp: "The timestamp of the request in UTC, formatted as 'YYYY-MM-DDTHH:mm:ssZ'.", + SignatureVersion: "The signature version. For STS GetCallerIdentity, this should be '1.0'.", + SignatureNonce: "A unique random string to prevent replay attacks.", + Signature: "The signature string calculated based on the request parameters and AccessKey Secret." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", + accessTokenTTL: "The lifetime for an access token in seconds.", + accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The maximum number of times that an access token can be used.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the auth method for." + } +} as const; + export const AWS_AUTH = { LOGIN: { identityId: "The ID of the identity to login.", @@ -400,6 +439,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.", @@ -417,6 +458,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.", @@ -621,7 +664,8 @@ export const PROJECTS = { autoCapitalization: "Disable or enable auto-capitalization for the project.", slug: "An optional slug for the project. (must be unique within the organization)", hasDeleteProtection: "Enable or disable delete protection for the project.", - secretSharing: "Enable or disable secret sharing for the project." + secretSharing: "Enable or disable secret sharing for the project.", + showSnapshotsLegacy: "Enable or disable legacy snapshots for the project." }, GET_KEY: { workspaceId: "The ID of the project to get the key from." @@ -1107,6 +1151,14 @@ export const DYNAMIC_SECRET_LEASES = { leaseId: "The ID of the dynamic secret lease.", isForced: "A boolean flag to delete the the dynamic secret from Infisical without trying to remove it from external provider. Used when the dynamic secret got modified externally." + }, + KUBERNETES: { + CREATE: { + config: { + namespace: + "The Kubernetes namespace to create the lease in. If not specified, the first namespace defined in the configuration will be used." + } + } } } as const; export const SECRET_TAGS = { @@ -2156,6 +2208,11 @@ export const AppConnections = { code: "The OAuth code to use to connect with Azure Client Secrets.", tenantId: "The Tenant ID to use to connect with Azure Client Secrets." }, + AZURE_DEVOPS: { + code: "The OAuth code to use to connect with Azure DevOps.", + tenantId: "The Tenant ID to use to connect with Azure DevOps.", + orgName: "The Organization name to use to connect with Azure DevOps." + }, OCI: { userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", tenancyOcid: "The OCID (Oracle Cloud Identifier) of the tenancy in Oracle Cloud Infrastructure.", @@ -2270,9 +2327,14 @@ export const SecretSyncs = { "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", label: "An optional label to assign to secrets created in Azure App Configuration." }, + AZURE_DEVOPS: { + devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", + devopsProjectName: "The name of the Azure DevOps project to sync secrets to." + }, GCP: { scope: "The Google project scope that secrets should be synced to.", - projectId: "The ID of the Google project secrets should be synced to." + projectId: "The ID of the Google project secrets should be synced to.", + locationId: 'The ID of the Google project location secrets should be synced to (ie "us-west4").' }, DATABRICKS: { scope: "The Databricks secret scope that secrets should be synced to." diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index cb53a71a6..6b9d33c2a 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -213,6 +213,12 @@ const envSchema = z GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), + DYNAMIC_SECRET_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + ), + DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( + process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ @@ -255,6 +261,10 @@ const envSchema = z DATADOG_SERVICE: zpStr(z.string().optional().default("infisical-core")), DATADOG_HOSTNAME: zpStr(z.string().optional()), + // PIT + PIT_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("2")), + PIT_TREE_CHECKPOINT_WINDOW: zpStr(z.string().optional().default("30")), + /* CORS ----------------------------------------------------------------------------- */ CORS_ALLOWED_ORIGINS: zpStr( z diff --git a/backend/src/lib/gateway/gateway.ts b/backend/src/lib/gateway/gateway.ts new file mode 100644 index 000000000..46481a049 --- /dev/null +++ b/backend/src/lib/gateway/gateway.ts @@ -0,0 +1,428 @@ +/* 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) { + if (!targetHost && !targetPort) { + command = `FORWARD-HTTP`; + logger.debug(`Using HTTP proxy mode, no target URL provided [command=${command.trim()}]`); + } else { + if (!targetHost || targetPort === undefined) { + throw new BadRequestError({ + message: `Target host and port are required for HTTP proxy mode with custom target` + }); + } + + const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http) + command = `FORWARD-HTTP ${targetUrl}`; + logger.debug(`Using HTTP proxy mode, custom target URL provided [command=${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, custom target URL provided [command=${command.trim()}]`); + } + } + } + + command += "\n"; + } else if (protocol === GatewayProxyProtocol.Tcp) { + if (!targetHost || !targetPort) { + throw new BadRequestError({ + message: `Target host and port are required for TCP proxy mode` + }); + } + + // 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 4d6401eac..9292473e5 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -1,392 +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 axios from "axios"; - -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.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; -}; - -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"); - 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(); - } -}; +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..8552fbf54 --- /dev/null +++ b/backend/src/lib/gateway/types.ts @@ -0,0 +1,43 @@ +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", + UseGatewayK8sServiceAccount = "use-k8s-sa" +} + +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/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts index 08343e962..4aa0d1f63 100644 --- a/backend/src/lib/template/validate-handlebars.ts +++ b/backend/src/lib/template/validate-handlebars.ts @@ -7,13 +7,24 @@ type SanitizationArg = { allowedExpressions?: (arg: string) => boolean; }; +const isValidExpression = (expression: string, dto: SanitizationArg): boolean => { + // Allow helper functions (replace, truncate) + const allowedHelpers = ["replace", "truncate", "random"]; + if (allowedHelpers.includes(expression)) { + return true; + } + + // Check regular allowed expressions + return dto?.allowedExpressions?.(expression) || false; +}; + export const validateHandlebarTemplate = (templateName: string, template: string, dto: SanitizationArg) => { const parsedAst = handlebars.parse(template); parsedAst.body.forEach((el) => { if (el.type === "ContentStatement") return; 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; + if (path.type === "PathExpression" && isValidExpression(path.original, dto)) return; } logger.error(el, "Template sanitization failed"); throw new BadRequestError({ message: `Template sanitization failed: ${templateName}` }); @@ -26,7 +37,7 @@ export const isValidHandleBarTemplate = (template: string, dto: SanitizationArg) 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; + if (path.type === "PathExpression" && isValidExpression(path.original, dto)) return true; } return false; }); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index e4d654998..25677841d 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -60,6 +60,7 @@ export enum QueueName { ImportSecretsFromExternalSource = "import-secrets-from-external-source", AppConnectionSecretSync = "app-connection-secret-sync", SecretRotationV2 = "secret-rotation-v2", + FolderTreeCheckpoint = "folder-tree-checkpoint", InvalidateCache = "invalidate-cache", SecretScanningV2 = "secret-scanning-v2" } @@ -94,6 +95,7 @@ export enum QueueJobs { SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", 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", @@ -209,6 +211,12 @@ export type TQueueJobTypes = { name: QueueJobs.ProjectV3Migration; payload: { projectId: string }; }; + [QueueName.FolderTreeCheckpoint]: { + name: QueueJobs.CreateFolderTreeCheckpoint; + payload: { + envId: string; + }; + }; [QueueName.ImportSecretsFromExternalSource]: { name: QueueJobs.ImportSecretsFromExternalSource; payload: { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index afea5c9f9..f065bfbed 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -155,6 +155,12 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { oidc: token?.identityAuth?.oidc }); } + if (token?.identityAuth?.kubernetes) { + requestContext.set("identityAuthInfo", { + identityId: identity.identityId, + kubernetes: token?.identityAuth?.kubernetes + }); + } break; } case AuthMode.SERVICE_TOKEN: { diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 22c097726..b71451b6e 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -57,9 +57,12 @@ export const registerServeUI = async ( reply.callNotFound(); return; } - // reference: https://github.com/fastify/fastify-static?tab=readme-ov-file#managing-cache-control-headers - // to avoid ui bundle skew on new deployment - return reply.sendFile("index.html", { maxAge: 0, immutable: false }); + + // This should help avoid caching any chunks (temp fix) + void reply.header("Cache-Control", "no-cache, no-store, must-revalidate, private, max-age=0"); + void reply.header("Pragma", "no-cache"); + void reply.header("Expires", "0"); + return reply.sendFile("index.html"); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 509a2ef1f..262e8f373 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -60,6 +60,7 @@ import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; @@ -154,6 +155,14 @@ import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-gr import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; +import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; +import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; +import { folderCommitQueueServiceFactory } from "@app/services/folder-commit/folder-commit-queue"; +import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; +import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; +import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; +import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -163,6 +172,8 @@ import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; +import { identityAliCloudAuthDALFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-dal"; +import { identityAliCloudAuthServiceFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; @@ -374,6 +385,7 @@ export const registerRoutes = async ( const identityUaDAL = identityUaDALFactory(db); const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); + const identityAliCloudAuthDAL = identityAliCloudAuthDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const identityOciAuthDAL = identityOciAuthDALFactory(db); @@ -583,6 +595,41 @@ export const registerRoutes = async ( projectRoleDAL, permissionService }); + + const folderCommitChangesDAL = folderCommitChangesDALFactory(db); + const folderCheckpointDAL = folderCheckpointDALFactory(db); + const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db); + const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); + const folderCommitDAL = folderCommitDALFactory(db); + const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); + const folderCommitQueueService = folderCommitQueueServiceFactory({ + queueService, + folderTreeCheckpointDAL, + keyStore, + folderTreeCheckpointResourcesDAL, + folderCommitDAL, + folderDAL + }); + const folderCommitService = folderCommitServiceFactory({ + folderCommitDAL, + folderCommitChangesDAL, + folderCheckpointDAL, + folderTreeCheckpointDAL, + userDAL, + identityDAL, + folderDAL, + folderVersionDAL, + secretVersionV2BridgeDAL, + projectDAL, + folderCheckpointResourcesDAL, + secretV2BridgeDAL, + folderTreeCheckpointResourcesDAL, + folderCommitQueueService, + permissionService, + kmsService, + secretTagDAL, + resourceMetadataDAL + }); const scimService = scimServiceFactory({ licenseService, scimDAL, @@ -987,6 +1034,7 @@ export const registerRoutes = async ( projectMembershipDAL, projectBotDAL, secretDAL, + folderCommitService, secretBlindIndexDAL, secretVersionDAL, secretTagDAL, @@ -1034,6 +1082,7 @@ export const registerRoutes = async ( secretReminderRecipientsDAL, orgService, resourceMetadataDAL, + folderCommitService, secretSyncQueue }); @@ -1110,6 +1159,7 @@ export const registerRoutes = async ( snapshotDAL, snapshotFolderDAL, snapshotSecretDAL, + folderCommitService, secretVersionDAL, folderVersionDAL, secretTagDAL, @@ -1136,7 +1186,8 @@ export const registerRoutes = async ( folderVersionDAL, projectEnvDAL, snapshotService, - projectDAL + projectDAL, + folderCommitService }); const secretImportService = secretImportServiceFactory({ @@ -1161,6 +1212,7 @@ export const registerRoutes = async ( const secretV2BridgeService = secretV2BridgeServiceFactory({ folderDAL, secretVersionDAL: secretVersionV2BridgeDAL, + folderCommitService, secretQueueService, secretDAL: secretV2BridgeDAL, permissionService, @@ -1204,7 +1256,8 @@ export const registerRoutes = async ( projectSlackConfigDAL, resourceMetadataDAL, projectMicrosoftTeamsConfigDAL, - microsoftTeamsService + microsoftTeamsService, + folderCommitService }); const secretService = secretServiceFactory({ @@ -1291,7 +1344,8 @@ export const registerRoutes = async ( secretV2BridgeDAL, secretVersionV2TagBridgeDAL: secretVersionTagV2BridgeDAL, secretVersionV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }); const secretRotationQueue = secretRotationQueueFactory({ @@ -1303,6 +1357,7 @@ export const registerRoutes = async ( projectBotService, secretVersionV2BridgeDAL, secretV2BridgeDAL, + folderCommitService, kmsService }); @@ -1430,6 +1485,14 @@ export const registerRoutes = async ( licenseService }); + const identityAliCloudAuthService = identityAliCloudAuthServiceFactory({ + identityAccessTokenDAL, + identityAliCloudAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService + }); + const identityAwsAuthService = identityAwsAuthServiceFactory({ identityAccessTokenDAL, identityAwsAuthDAL, @@ -1454,6 +1517,15 @@ export const registerRoutes = async ( permissionService }); + const pitService = pitServiceFactory({ + folderCommitService, + secretService, + folderService, + permissionService, + folderDAL, + projectEnvDAL + }); + const identityOidcAuthService = identityOidcAuthServiceFactory({ identityOidcAuthDAL, identityOrgMembershipDAL, @@ -1516,7 +1588,9 @@ export const registerRoutes = async ( dynamicSecretProviders, folderDAL, licenseService, - kmsService + kmsService, + userDAL, + identityDAL }); const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, @@ -1595,7 +1669,9 @@ export const registerRoutes = async ( secretDAL: secretV2BridgeDAL, queueService, secretV2BridgeService, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService, + folderVersionDAL }); const migrationService = externalMigrationServiceFactory({ @@ -1705,6 +1781,7 @@ export const registerRoutes = async ( auditLogService, secretV2BridgeDAL, secretTagDAL, + folderCommitService, secretVersionTagV2BridgeDAL, secretVersionV2BridgeDAL, keyStore, @@ -1865,6 +1942,7 @@ export const registerRoutes = async ( identityUa: identityUaService, identityKubernetesAuth: identityKubernetesAuthService, identityGcpAuth: identityGcpAuthService, + identityAliCloudAuth: identityAliCloudAuthService, identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, identityOciAuth: identityOciAuthService, @@ -1893,6 +1971,7 @@ export const registerRoutes = async ( certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, + pit: pitService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, @@ -1927,6 +2006,7 @@ export const registerRoutes = async ( microsoftTeams: microsoftTeamsService, assumePrivileges: assumePrivilegeService, githubOrgSync: githubOrgSyncConfigService, + folderCommit: folderCommitService, secretScanningV2: secretScanningV2Service }); diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index a26293ac8..ce51b1079 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -262,7 +262,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({ kmsCertificateKeyId: true, auditLogsRetentionDays: true, hasDeleteProtection: true, - secretSharing: true + secretSharing: true, + showSnapshotsLegacy: true }); export const SanitizedTagSchema = SecretTagsSchema.pick({ 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 f523bb218..53c503e4a 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 @@ -1,6 +1,10 @@ import { z } from "zod"; import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci"; +import { + OracleDBConnectionListItemSchema, + SanitizedOracleDBConnectionSchema +} from "@app/ee/services/app-connections/oracledb"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -19,6 +23,10 @@ import { AzureClientSecretsConnectionListItemSchema, SanitizedAzureClientSecretsConnectionSchema } from "@app/services/app-connection/azure-client-secrets"; +import { + AzureDevOpsConnectionListItemSchema, + SanitizedAzureDevOpsConnectionSchema +} from "@app/services/app-connection/azure-devops/azure-devops-schemas"; import { AzureKeyVaultConnectionListItemSchema, SanitizedAzureKeyVaultConnectionSchema @@ -75,6 +83,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, + ...SanitizedAzureDevOpsConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, ...SanitizedTerraformCloudConnectionSchema.options, @@ -90,6 +99,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedLdapConnectionSchema.options, ...SanitizedTeamCityConnectionSchema.options, ...SanitizedOCIConnectionSchema.options, + ...SanitizedOracleDBConnectionSchema.options, ...SanitizedOnePassConnectionSchema.options ]); @@ -100,6 +110,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, + AzureDevOpsConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, TerraformCloudConnectionListItemSchema, @@ -115,6 +126,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ LdapConnectionListItemSchema, TeamCityConnectionListItemSchema, OCIConnectionListItemSchema, + OracleDBConnectionListItemSchema, OnePassConnectionListItemSchema ]); diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts new file mode 100644 index 000000000..a77c3d3dd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-devops-connection-router.ts @@ -0,0 +1,49 @@ +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 { + CreateAzureDevOpsConnectionSchema, + SanitizedAzureDevOpsConnectionSchema, + UpdateAzureDevOpsConnectionSchema +} from "@app/services/app-connection/azure-devops/azure-devops-schemas"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureDevOpsConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureDevOps, + server, + sanitizedResponseSchema: SanitizedAzureDevOpsConnectionSchema, + createSchema: CreateAzureDevOpsConnectionSchema, + updateSchema: UpdateAzureDevOpsConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z.object({ name: z.string(), id: z.string(), appId: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects = await server.services.appConnection.azureDevOps.listProjects(connectionId, req.permission); + + return { projects }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts index f92d5e668..c88308c64 100644 --- a/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts @@ -45,4 +45,37 @@ export const registerGcpConnectionRouter = async (server: FastifyZodProvider) => return projects; } }); + + server.route({ + method: "GET", + url: `/:connectionId/secret-manager-project-locations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ displayName: z.string(), locationId: z.string() }).array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { + params: { connectionId }, + query: { projectId } + } = req; + + const locations = await server.services.appConnection.gcp.listSecretManagerProjectLocations( + { connectionId, projectId }, + req.permission + ); + + return locations; + } + }); }; 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 7085b3364..5f07f1be7 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,4 +1,5 @@ import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router"; +import { registerOracleDBConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oracledb-connection-router"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { registerOnePassConnectionRouter } from "./1password-connection-router"; @@ -6,6 +7,7 @@ import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; +import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; @@ -34,6 +36,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + server.route({ + method: "POST", + url: "/alicloud-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.AliCloudAuth], + description: "Login with Alibaba Cloud Auth", + body: z.object({ + identityId: z.string().trim().describe(ALICLOUD_AUTH.LOGIN.identityId), + Action: z.enum(["GetCallerIdentity"]).describe(ALICLOUD_AUTH.LOGIN.Action), + Format: z.enum(["JSON"]).describe(ALICLOUD_AUTH.LOGIN.Format), + Version: z + .string() + .refine((val) => new RE2("^\\d{4}-\\d{2}-\\d{2}$").test(val), { + message: "Version must be in YYYY-MM-DD format" + }) + .describe(ALICLOUD_AUTH.LOGIN.Version), + AccessKeyId: z + .string() + .refine((val) => new RE2("^[A-Za-z0-9]+$").test(val), { + message: "AccessKeyId must be alphanumeric" + }) + .describe(ALICLOUD_AUTH.LOGIN.AccessKeyId), + SignatureMethod: z.enum(["HMAC-SHA1"]).describe(ALICLOUD_AUTH.LOGIN.SignatureMethod), + Timestamp: z + .string() + .datetime({ + message: "Timestamp must be in YYYY-MM-DDTHH:mm:ssZ format" + }) + .refine((val) => val.endsWith("Z"), { + message: "Timestamp must be in YYYY-MM-DDTHH:mm:ssZ format" + }) + .describe(ALICLOUD_AUTH.LOGIN.Timestamp), + SignatureVersion: z.enum(["1.0"]).describe(ALICLOUD_AUTH.LOGIN.SignatureVersion), + SignatureNonce: z + .string() + .refine((val) => new RE2("^[a-zA-Z0-9-_.]+$").test(val), { + message: + "SignatureNonce must be at least 1 character long and contain only URL-safe characters (alphanumeric, -, _, .)" + }) + .describe(ALICLOUD_AUTH.LOGIN.SignatureNonce), + Signature: z + .string() + .refine((val) => new RE2("^[A-Za-z0-9+/=]+$").test(val), { + message: "Signature must be base64 characters" + }) + .describe(ALICLOUD_AUTH.LOGIN.Signature) + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAliCloudAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAliCloudAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_ALICLOUD_AUTH, + metadata: { + identityId: identityAliCloudAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAliCloudAuthId: identityAliCloudAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/alicloud-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.AliCloudAuth], + description: "Attach Alibaba Cloud Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ALICLOUD_AUTH.ATTACH.identityId) + }), + body: z + .object({ + allowedArns: validateArns.describe(ALICLOUD_AUTH.ATTACH.allowedArns), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(ALICLOUD_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .default(2592000) + .describe(ALICLOUD_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .min(1) + .max(315360000) + .default(2592000) + .describe(ALICLOUD_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .default(0) + .describe(ALICLOUD_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityAliCloudAuth: IdentityAlicloudAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAliCloudAuth = await server.services.identityAliCloudAuth.attachAliCloudAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAliCloudAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_ALICLOUD_AUTH, + metadata: { + identityId: identityAliCloudAuth.identityId, + allowedArns: identityAliCloudAuth.allowedArns, + accessTokenTTL: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAliCloudAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAliCloudAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/alicloud-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.AliCloudAuth], + description: "Update Alibaba Cloud Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(ALICLOUD_AUTH.UPDATE.identityId) + }), + body: z + .object({ + allowedArns: validateArns.describe(ALICLOUD_AUTH.UPDATE.allowedArns), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional() + .describe(ALICLOUD_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(0) + .max(315360000) + .optional() + .describe(ALICLOUD_AUTH.UPDATE.accessTokenTTL), + accessTokenNumUsesLimit: z + .number() + .int() + .min(0) + .optional() + .describe(ALICLOUD_AUTH.UPDATE.accessTokenNumUsesLimit), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .min(0) + .optional() + .describe(ALICLOUD_AUTH.UPDATE.accessTokenMaxTTL) + }) + .refine( + (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityAliCloudAuth: IdentityAlicloudAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAliCloudAuth = await server.services.identityAliCloudAuth.updateAliCloudAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + allowedArns: req.body.allowedArns + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAliCloudAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_ALICLOUD_AUTH, + metadata: { + identityId: identityAliCloudAuth.identityId, + allowedArns: identityAliCloudAuth.allowedArns, + accessTokenTTL: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAliCloudAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAliCloudAuth }; + } + }); + + server.route({ + method: "GET", + url: "/alicloud-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.AliCloudAuth], + description: "Retrieve Alibaba Cloud Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(ALICLOUD_AUTH.RETRIEVE.identityId) + }), + response: { + 200: z.object({ + identityAliCloudAuth: IdentityAlicloudAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAliCloudAuth = await server.services.identityAliCloudAuth.getAliCloudAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAliCloudAuth.orgId, + event: { + type: EventType.GET_IDENTITY_ALICLOUD_AUTH, + metadata: { + identityId: identityAliCloudAuth.identityId + } + } + }); + return { identityAliCloudAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/alicloud-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.AliCloudAuth], + description: "Delete Alibaba Cloud Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(ALICLOUD_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityAliCloudAuth: IdentityAlicloudAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAliCloudAuth = await server.services.identityAliCloudAuth.revokeIdentityAliCloudAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAliCloudAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_ALICLOUD_AUTH, + metadata: { + identityId: identityAliCloudAuth.identityId + } + } + }); + + return { identityAliCloudAuth }; + } + }); +}; 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..879310790 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, @@ -106,17 +108,21 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .string() .trim() .min(1) + .nullable() .describe(KUBERNETES_AUTH.ATTACH.kubernetesHost) .refine( - (val) => - characterValidator([ + (val) => { + if (val === null) return true; + + return characterValidator([ CharacterType.Alphabets, CharacterType.Numbers, CharacterType.Colon, CharacterType.Period, CharacterType.ForwardSlash, CharacterType.Hyphen - ])(val), + ])(val); + }, { message: "Kubernetes host must only contain alphabets, numbers, colons, periods, hyphen, and forward slashes." @@ -124,6 +130,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 +167,30 @@ 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.Api && !data.kubernetesHost) { + ctx.addIssue({ + path: ["kubernetesHost"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to API, a Kubernetes host must be provided" + }); + } + 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 @@ -185,7 +215,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide type: EventType.ADD_IDENTITY_KUBERNETES_AUTH, metadata: { identityId: identityKubernetesAuth.identityId, - kubernetesHost: identityKubernetesAuth.kubernetesHost, + kubernetesHost: identityKubernetesAuth.kubernetesHost ?? "", allowedNamespaces: identityKubernetesAuth.allowedNamespaces, allowedNames: identityKubernetesAuth.allowedNames, accessTokenTTL: identityKubernetesAuth.accessTokenTTL, @@ -225,6 +255,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .string() .trim() .min(1) + .nullable() .optional() .describe(KUBERNETES_AUTH.UPDATE.kubernetesHost) .refine( @@ -247,6 +278,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 +315,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 @@ -307,7 +358,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide type: EventType.UPDATE_IDENTITY_KUBENETES_AUTH, metadata: { identityId: identityKubernetesAuth.identityId, - kubernetesHost: identityKubernetesAuth.kubernetesHost, + kubernetesHost: identityKubernetesAuth.kubernetesHost ?? "", allowedNamespaces: identityKubernetesAuth.allowedNamespaces, allowedNames: identityKubernetesAuth.allowedNames, accessTokenTTL: identityKubernetesAuth.accessTokenTTL, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 76cf8761f..2363147b6 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -15,6 +15,7 @@ import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerExternalGroupOrgRoleMappingRouter } from "./external-group-org-role-mapping-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; +import { registerIdentityAliCloudAuthRouter } from "./identity-alicloud-auth-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; @@ -63,6 +64,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityKubernetesRouter); await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); + await authRouter.register(registerIdentityAliCloudAuthRouter); await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOciAuthRouter); diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 2a868864e..cc94adede 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -376,7 +376,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }) .optional() .describe(PROJECTS.UPDATE.slug), - secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing) + secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing), + showSnapshotsLegacy: z.boolean().optional().describe(PROJECTS.UPDATE.showSnapshotsLegacy) }), response: { 200: z.object({ @@ -397,7 +398,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { autoCapitalization: req.body.autoCapitalization, hasDeleteProtection: req.body.hasDeleteProtection, slug: req.body.slug, - secretSharing: req.body.secretSharing + secretSharing: req.body.secretSharing, + showSnapshotsLegacy: req.body.showSnapshotsLegacy }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, diff --git a/backend/src/server/routes/v1/secret-sync-routers/azure-devops-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/azure-devops-sync-router.ts new file mode 100644 index 000000000..8060e2237 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/azure-devops-sync-router.ts @@ -0,0 +1,17 @@ +import { + AzureDevOpsSyncSchema, + CreateAzureDevOpsSyncSchema, + UpdateAzureDevOpsSyncSchema +} from "@app/services/secret-sync/azure-devops"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerAzureDevOpsSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.AzureDevOps, + server, + responseSchema: AzureDevOpsSyncSchema, + createSchema: CreateAzureDevOpsSyncSchema, + updateSchema: UpdateAzureDevOpsSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index fbc636ffc..3f4276726 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -5,6 +5,7 @@ import { registerOnePassSyncRouter } from "./1password-sync-router"; import { registerAwsParameterStoreSyncRouter } from "./aws-parameter-store-sync-router"; import { registerAwsSecretsManagerSyncRouter } from "./aws-secrets-manager-sync-router"; import { registerAzureAppConfigurationSyncRouter } from "./azure-app-configuration-sync-router"; +import { registerAzureDevOpsSyncRouter } from "./azure-devops-sync-router"; import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; @@ -26,6 +27,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record return { groupMembership }; } }); + + server.route({ + method: "GET", + url: "/:projectId/groups/:groupId/users", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.ProjectGroups], + description: "Return project group users", + params: z.object({ + projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId), + groupId: z.string().trim().describe(GROUPS.LIST_USERS.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), + username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { users, totalCount } = await server.services.groupProject.listProjectGroupUsers({ + id: req.params.groupId, + projectId: req.params.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { users, totalCount }; + } + }); }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 227818bf0..19aabc55e 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", + AzureDevOps = "azure-devops", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", @@ -20,6 +21,7 @@ export enum AppConnection { LDAP = "ldap", TeamCity = "teamcity", OCI = "oci", + OracleDB = "oracledb", OnePass = "1password" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 4597d8f45..4a272bb5c 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -4,6 +4,7 @@ import { OCIConnectionMethod, validateOCIConnectionCredentials } from "@app/ee/services/app-connections/oci"; +import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee/services/app-connections/oracledb"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; @@ -39,6 +40,11 @@ import { getAzureClientSecretsConnectionListItem, validateAzureClientSecretsConnectionCredentials } from "./azure-client-secrets"; +import { AzureDevOpsConnectionMethod } from "./azure-devops/azure-devops-enums"; +import { + getAzureDevopsConnectionListItem, + validateAzureDevOpsConnectionCredentials +} from "./azure-devops/azure-devops-fns"; import { AzureKeyVaultConnectionMethod, getAzureKeyVaultConnectionListItem, @@ -98,6 +104,7 @@ export const listAppConnectionOptions = () => { getGcpConnectionListItem(), getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), + getAzureDevopsConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), getTerraformCloudConnectionListItem(), @@ -113,6 +120,7 @@ export const listAppConnectionOptions = () => { getLdapConnectionListItem(), getTeamCityConnectionListItem(), getOCIConnectionListItem(), + getOracleDBConnectionListItem(), getOnePassConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -173,6 +181,7 @@ export const validateAppConnectionCredentials = async ( validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureClientSecrets]: validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -186,6 +195,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.LDAP]: validateLdapConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -201,6 +211,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: + case AzureDevOpsConnectionMethod.OAuth: return "OAuth"; case AwsConnectionMethod.AccessKey: case OCIConnectionMethod.AccessKey: @@ -221,10 +232,12 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: + case OracleDBConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: + case AzureDevOpsConnectionMethod.AccessToken: return "Access Token"; case Auth0ConnectionMethod.ClientCredentials: return "Client Credentials"; @@ -270,6 +283,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.GCP]: platformManagedCredentialsNotSupported, [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, + [AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, @@ -284,6 +298,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.LDAP]: platformManagedCredentialsNotSupported, // we could support this in the future [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, [AppConnection.OCI]: platformManagedCredentialsNotSupported, + [AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.OnePass]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 0042fdf42..19967aa3b 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.AzureClientSecrets]: "Azure Client Secrets", + [AppConnection.AzureDevOps]: "Azure DevOps", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", @@ -22,6 +23,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.LDAP]: "LDAP", [AppConnection.TeamCity]: "TeamCity", [AppConnection.OCI]: "OCI", + [AppConnection.OracleDB]: "OracleDB", [AppConnection.OnePass]: "1Password" }; @@ -33,6 +35,7 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; -export type TSqlConnection = TPostgresConnection | TMsSqlConnection | TMySqlConnection; +export type TSqlConnection = TPostgresConnection | TMsSqlConnection | TMySqlConnection | TOracleDBConnection; export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput @@ -161,6 +174,7 @@ export type TAppConnectionInput = { id: string } & ( | TGcpConnectionInput | TAzureKeyVaultConnectionInput | TAzureAppConfigurationConnectionInput + | TAzureDevOpsConnectionInput | TDatabricksConnectionInput | THumanitecConnectionInput | TTerraformCloudConnectionInput @@ -176,10 +190,15 @@ export type TAppConnectionInput = { id: string } & ( | TLdapConnectionInput | TTeamCityConnectionInput | TOCIConnectionInput + | TOracleDBConnectionInput | TOnePassConnectionInput ); -export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput | TMySqlConnectionInput; +export type TSqlConnectionInput = + | TPostgresConnectionInput + | TMsSqlConnectionInput + | TMySqlConnectionInput + | TOracleDBConnectionInput; export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -197,6 +216,7 @@ export type TAppConnectionConfig = | TGcpConnectionConfig | TAzureKeyVaultConnectionConfig | TAzureAppConfigurationConnectionConfig + | TAzureDevOpsConnectionConfig | TAzureClientSecretsConnectionConfig | TDatabricksConnectionConfig | THumanitecConnectionConfig @@ -220,6 +240,7 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateAzureKeyVaultConnectionCredentialsSchema | TValidateAzureAppConfigurationConnectionCredentialsSchema | TValidateAzureClientSecretsConnectionCredentialsSchema + | TValidateAzureDevOpsConnectionCredentialsSchema | TValidateDatabricksConnectionCredentialsSchema | TValidateHumanitecConnectionCredentialsSchema | TValidatePostgresConnectionCredentialsSchema @@ -234,6 +255,7 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateLdapConnectionCredentialsSchema | TValidateTeamCityConnectionCredentialsSchema | TValidateOCIConnectionCredentialsSchema + | TValidateOracleDBConnectionCredentialsSchema | TValidateOnePassConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts new file mode 100644 index 000000000..f724a0b4a --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts @@ -0,0 +1,4 @@ +export enum AzureDevOpsConnectionMethod { + OAuth = "oauth", + AccessToken = "access-token" +} diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts new file mode 100644 index 000000000..644747353 --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -0,0 +1,269 @@ +/* eslint-disable no-case-declarations */ +import { AxiosError, AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { + decryptAppConnectionCredentials, + encryptAppConnectionCredentials, + getAppConnectionMethodName +} from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "../app-connection-dal"; +import { AppConnection } from "../app-connection-enums"; +import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; +import { + ExchangeCodeAzureResponse, + TAzureDevOpsConnectionConfig, + TAzureDevOpsConnectionCredentials +} from "./azure-devops-types"; + +export const getAzureDevopsConnectionListItem = () => { + const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + + return { + name: "Azure DevOps" as const, + app: AppConnection.AzureDevOps as const, + methods: Object.values(AzureDevOpsConnectionMethod) as [ + AzureDevOpsConnectionMethod.OAuth, + AzureDevOpsConnectionMethod.AccessToken + ], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + }; +}; + +export const getAzureDevopsConnection = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureDevOps) { + throw new BadRequestError({ + message: `Connection with ID '${connectionId}' is not an Azure DevOps connection` + }); + } + + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionCredentials; + + // Handle different connection methods + switch (appConnection.method) { + case AzureDevOpsConnectionMethod.OAuth: + const appCfg = getConfig(); + if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } + + if (!("refreshToken" in credentials)) { + throw new BadRequestError({ message: "Invalid OAuth credentials" }); + } + + const { refreshToken, tenantId } = credentials; + const currentTime = Date.now(); + + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + refresh_token: refreshToken + }) + ); + + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + + return data.access_token; + + case AzureDevOpsConnectionMethod.AccessToken: + if (!("accessToken" in credentials)) { + throw new BadRequestError({ message: "Invalid API token credentials" }); + } + // For access token, return the basic auth token directly + return credentials.accessToken; + + default: + throw new BadRequestError({ message: `Unsupported connection method` }); + } +}; + +export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDevOpsConnectionConfig) => { + const { credentials: inputCredentials, method } = config; + + const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + + switch (method) { + case AzureDevOpsConnectionMethod.OAuth: + if (!SITE_URL) { + throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); + } + + if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + const oauthCredentials = inputCredentials as { code: string; tenantId: string }; + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + + const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string }; + return { + tenantId: oauthCredentials.tenantId, + orgName: oauthCredentials.orgName, + accessToken: tokenResp.data.access_token, + refreshToken: tokenResp.data.refresh_token, + expiresAt: Date.now() + tokenResp.data.expires_in * 1000 + }; + + case AzureDevOpsConnectionMethod.AccessToken: + const accessTokenCredentials = inputCredentials as { accessToken: string; orgName?: string }; + + try { + if (accessTokenCredentials.orgName) { + // Validate against specific organization + const response = await request.get( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(accessTokenCredentials.orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`, + { + headers: { + Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ + message: `Failed to validate connection: ${response.status}` + }); + } + + return { + accessToken: accessTokenCredentials.accessToken, + orgName: accessTokenCredentials.orgName + }; + } + // Validate via profile and discover organizations + const profileResponse = await request.get<{ displayName: string }>( + `https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.1`, + { + headers: { + Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}` + } + } + ); + + let organizations: Array<{ accountId: string; accountName: string; accountUri: string }> = []; + try { + const orgsResponse = await request.get<{ + value: Array<{ accountId: string; accountName: string; accountUri: string }>; + }>(`https://app.vssps.visualstudio.com/_apis/accounts?api-version=7.1`, { + headers: { + Authorization: `Basic ${Buffer.from(`:${accessTokenCredentials.accessToken}`).toString("base64")}` + } + }); + organizations = orgsResponse.data.value || []; + } catch (orgError) { + logger.warn(orgError, "Could not fetch organizations automatically:"); + } + + return { + accessToken: accessTokenCredentials.accessToken, + userDisplayName: profileResponse.data.displayName, + organizations: organizations.map((org) => ({ + accountId: org.accountId, + accountName: org.accountName, + accountUri: org.accountUri + })) + }; + } catch (error) { + if (error instanceof AxiosError) { + const errorMessage = accessTokenCredentials.orgName + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `Failed to validate access token for organization '${accessTokenCredentials.orgName}': ${error.response?.data?.message || error.message}` + : `Invalid Azure DevOps Personal Access Token: ${error.response?.status === 401 ? "Token is invalid or expired" : error.message}`; + + throw new BadRequestError({ message: errorMessage }); + } + throw new BadRequestError({ + message: `Unable to validate Azure DevOps token` + }); + } + + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}` + }); + } +}; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts new file mode 100644 index 000000000..e07ed008d --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -0,0 +1,112 @@ +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 { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; + +export const AzureDevOpsConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required").describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.code), + tenantId: z.string().trim().min(1, "Tenant ID required").describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName) +}); + +export const AzureDevOpsConnectionOAuthOutputCredentialsSchema = z.object({ + tenantId: z.string(), + orgName: z.string(), + accessToken: z.string(), + refreshToken: z.string(), + expiresAt: z.number() +}); + +export const AzureDevOpsConnectionAccessTokenInputCredentialsSchema = z.object({ + accessToken: z.string().trim().min(1, "Access Token required"), + orgName: z.string().trim().min(1, "Organization name required") +}); + +export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object({ + accessToken: z.string(), + orgName: z.string() +}); + +export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureDevOpsConnectionMethod.OAuth) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) + }), + z.object({ + method: z + .literal(AzureDevOpsConnectionMethod.AccessToken) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) + }) +]); + +export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureDevOps) +); + +export const UpdateAzureDevOpsConnectionSchema = z + .object({ + credentials: z + .union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureDevOps)); + +const BaseAzureDevOpsConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.AzureDevOps) +}); + +export const AzureDevOpsConnectionSchema = z.intersection( + BaseAzureDevOpsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AzureDevOpsConnectionMethod.OAuth), + credentials: AzureDevOpsConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureDevOpsConnectionMethod.AccessToken), + credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema + }) + ]) +); + +export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureDevOpsConnectionSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.OAuth), + credentials: AzureDevOpsConnectionOAuthOutputCredentialsSchema.pick({ + tenantId: true, + orgName: true + }) + }), + BaseAzureDevOpsConnectionSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.AccessToken), + credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ + orgName: true + }) + }) +]); + +export const AzureDevOpsConnectionListItemSchema = z.object({ + name: z.literal("Azure DevOps"), + app: z.literal(AppConnection.AzureDevOps), + methods: z.nativeEnum(AzureDevOpsConnectionMethod).array(), + oauthClientId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts new file mode 100644 index 000000000..a85430e4d --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts @@ -0,0 +1,127 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable no-case-declarations */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; +import { getAzureDevopsConnection } from "./azure-devops-fns"; +import { TAzureDevOpsConnection } from "./azure-devops-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +type TAzureDevOpsProject = { + id: string; + name: string; + description?: string; + url?: string; + state?: string; + visibility?: string; + lastUpdateTime?: string; + revision?: number; + abbreviation?: string; + defaultTeamImageUrl?: string; +}; + +type TAzureDevOpsProjectsResponse = { + count: number; + value: TAzureDevOpsProject[]; +}; + +const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: string) => { + switch (appConnection.method) { + case AzureDevOpsConnectionMethod.OAuth: + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }; + case AzureDevOpsConnectionMethod.AccessToken: + // For access token, create Basic auth header + const basicAuthToken = Buffer.from(`user:${accessToken}`).toString("base64"); + return { + Authorization: `Basic ${basicAuthToken}`, + Accept: "application/json" + }; + default: + throw new BadRequestError({ message: "Unsupported connection method" }); + } +}; + +const listAzureDevOpsProjects = async ( + appConnection: TAzureDevOpsConnection, + appConnectionDAL: Pick, + kmsService: Pick +): Promise => { + const accessToken = await getAzureDevopsConnection(appConnection.id, appConnectionDAL, kmsService); + + // Both OAuth and access Token methods use organization name from credentials + const credentials = appConnection.credentials as { orgName: string }; + const { orgName } = credentials; + + // Use the standard Azure DevOps Projects API endpoint + // This endpoint returns only projects that the authenticated user has access to + const devOpsEndpoint = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.1`; + try { + const { data } = await request.get(devOpsEndpoint, { + headers: getAuthHeaders(appConnection, accessToken) + }); + + return data.value || []; + } catch (error) { + if (error instanceof AxiosError) { + // Provide more specific error messages based on the response + if (error?.response?.status === 401) { + throw new Error( + `Authentication failed for Azure DevOps organization: ${orgName}. Please check your credentials and ensure the token has the required scopes (vso.project or vso.profile).` + ); + } else if (error?.response?.status === 403) { + throw new Error( + `Access denied to Azure DevOps organization: ${orgName}. Please ensure the user has access to the organization.` + ); + } else if (error?.response?.status === 404) { + throw new Error(`Azure DevOps organization not found: ${orgName}. Please verify the organization name.`); + } + } + throw error; + } +}; + +export const azureDevOpsConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.AzureDevOps, connectionId, actor); + + const projects = await listAzureDevOpsProjects(appConnection, appConnectionDAL, kmsService); + + return projects.map((project) => ({ + id: project.id, + name: project.name, + appId: project.id, + description: project.description, + url: project.url, + state: project.state, + visibility: project.visibility, + lastUpdateTime: project.lastUpdateTime, + revision: project.revision, + abbreviation: project.abbreviation, + defaultTeamImageUrl: project.defaultTeamImageUrl + })); + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts new file mode 100644 index 000000000..62a80c1c5 --- /dev/null +++ b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureDevOpsConnectionOAuthOutputCredentialsSchema, + AzureDevOpsConnectionSchema, + CreateAzureDevOpsConnectionSchema, + ValidateAzureDevOpsConnectionCredentialsSchema +} from "./azure-devops-schemas"; + +export type TAzureDevOpsConnection = z.infer; + +export type TAzureDevOpsConnectionInput = z.infer & { + app: AppConnection.AzureDevOps; +}; + +export type TValidateAzureDevOpsConnectionCredentialsSchema = typeof ValidateAzureDevOpsConnectionCredentialsSchema; + +export type TAzureDevOpsConnectionConfig = DiscriminativePick< + TAzureDevOpsConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TAzureDevOpsConnectionCredentials = z.infer; + +export interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + +export interface TAzureRegisteredApp { + id: string; + appId: string; + displayName: string; + description?: string; + createdDateTime: string; + identifierUris?: string[]; + signInAudience?: string; +} + +export interface TAzureListRegisteredAppsResponse { + "@odata.context": string; + "@odata.nextLink"?: string; + value: TAzureRegisteredApp[]; +} diff --git a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts index 8bde74062..d1533b696 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts @@ -11,8 +11,10 @@ import { AppConnection } from "../app-connection-enums"; import { GcpConnectionMethod } from "./gcp-connection-enums"; import { GCPApp, + GCPGetProjectLocationsRes, GCPGetProjectsRes, GCPGetServiceRes, + GCPLocation, TGcpConnection, TGcpConnectionConfig } from "./gcp-connection-types"; @@ -145,6 +147,45 @@ export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) return projects; }; +export const getGcpSecretManagerProjectLocations = async (projectId: string, appConnection: TGcpConnection) => { + const accessToken = await getGcpConnectionAuthToken(appConnection); + + let gcpLocations: GCPLocation[] = []; + + const pageSize = 100; + let pageToken: string | undefined; + let hasMorePages = true; + + while (hasMorePages) { + const params = new URLSearchParams({ + pageSize: String(pageSize), + ...(pageToken ? { pageToken } : {}) + }); + + // eslint-disable-next-line no-await-in-loop + const { data } = await request.get( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${projectId}/locations`, + { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + gcpLocations = gcpLocations.concat(data.locations); + + if (!data.nextPageToken) { + hasMorePages = false; + } + + pageToken = data.nextPageToken; + } + + return gcpLocations.sort((a, b) => a.displayName.localeCompare(b.displayName)); +}; + export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => { // Check if provided service account email suffix matches organization ID. // We do this to mitigate confused deputy attacks in multi-tenant instances diff --git a/backend/src/services/app-connection/gcp/gcp-connection-service.ts b/backend/src/services/app-connection/gcp/gcp-connection-service.ts index 96b795a8f..74f2ab2c4 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-service.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-service.ts @@ -1,8 +1,8 @@ import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { getGcpSecretManagerProjects } from "./gcp-connection-fns"; -import { TGcpConnection } from "./gcp-connection-types"; +import { getGcpSecretManagerProjectLocations, getGcpSecretManagerProjects } from "./gcp-connection-fns"; +import { TGcpConnection, TGetGCPProjectLocationsDTO } from "./gcp-connection-types"; type TGetAppConnectionFunc = ( app: AppConnection, @@ -23,7 +23,23 @@ export const gcpConnectionService = (getAppConnection: TGetAppConnectionFunc) => } }; + const listSecretManagerProjectLocations = async ( + { connectionId, projectId }: TGetGCPProjectLocationsDTO, + actor: OrgServiceActor + ) => { + const appConnection = await getAppConnection(AppConnection.GCP, connectionId, actor); + + try { + const locations = await getGcpSecretManagerProjectLocations(projectId, appConnection); + + return locations; + } catch (error) { + return []; + } + }; + return { - listSecretManagerProjects + listSecretManagerProjects, + listSecretManagerProjectLocations }; }; diff --git a/backend/src/services/app-connection/gcp/gcp-connection-types.ts b/backend/src/services/app-connection/gcp/gcp-connection-types.ts index 2bb518820..4dc4bd131 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-types.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-types.ts @@ -38,6 +38,22 @@ export type GCPGetProjectsRes = { nextPageToken?: string; }; +export type GCPLocation = { + name: string; + locationId: string; + displayName: string; +}; + +export type GCPGetProjectLocationsRes = { + locations: GCPLocation[]; + nextPageToken?: string; +}; + +export type TGetGCPProjectLocationsDTO = { + projectId: string; + connectionId: string; +}; + export type GCPGetServiceRes = { name: string; parent: string; 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 7df1929ba..33cc8257d 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 @@ -16,7 +16,8 @@ const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const SQL_CONNECTION_CLIENT_MAP = { [AppConnection.Postgres]: "pg", [AppConnection.MsSql]: "mssql", - [AppConnection.MySql]: "mysql2" + [AppConnection.MySql]: "mysql2", + [AppConnection.OracleDB]: "oracledb" }; const getConnectionConfig = ({ @@ -57,6 +58,17 @@ const getConnectionConfig = ({ : false }; } + + case AppConnection.OracleDB: { + return { + ssl: sslEnabled + ? { + sslCA: sslCertificate, + sslServerDNMatch: sslRejectUnauthorized + } + : false + }; + } default: throw new Error(`Unhandled SQL Connection Config: ${app as AppConnection}`); } @@ -114,7 +126,8 @@ export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< > = { [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]], [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]], - [AppConnection.MySql]: ({ username, password }) => [`ALTER USER ??@'%' IDENTIFIED BY '${password}';`, [username]] + [AppConnection.MySql]: ({ username, password }) => [`ALTER USER ??@'%' IDENTIFIED BY '${password}';`, [username]], + [AppConnection.OracleDB]: ({ username, password }) => [`ALTER USER ?? IDENTIFIED BY "${password}"`, [username]] }; export const transferSqlConnectionCredentialsToPlatform = async ( diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index bee85b14c..64ba573d5 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -397,7 +397,7 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId); + const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); const selectedOrg = await orgDAL.findById(organizationId); if (!hasOrganizationMembership) { diff --git a/backend/src/services/external-migration/external-migration-fns.ts b/backend/src/services/external-migration/external-migration-fns.ts index 856b39012..018d7bd43 100644 --- a/backend/src/services/external-migration/external-migration-fns.ts +++ b/backend/src/services/external-migration/external-migration-fns.ts @@ -10,6 +10,7 @@ import { chunkArray } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; @@ -18,6 +19,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvServiceFactory } from "../project-env/project-env-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { fnSecretBulkInsert, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; @@ -42,6 +44,8 @@ export type TImportDataIntoInfisicalDTO = { projectService: Pick; projectEnvService: Pick; secretV2BridgeService: Pick; + folderCommitService: Pick; + folderVersionDAL: Pick; input: TImportInfisicalDataCreate; }; @@ -507,6 +511,8 @@ export const importDataIntoInfisicalFn = async ({ secretVersionTagDAL, folderDAL, resourceMetadataDAL, + folderVersionDAL, + folderCommitService, input: { data, actor, actorId, actorOrgId, actorAuthMethod } }: TImportDataIntoInfisicalDTO) => { // Import data to infisical @@ -599,6 +605,36 @@ export const importDataIntoInfisicalFn = async ({ tx ); + const newFolderVersion = await folderVersionDAL.create( + { + name: newFolder.name, + envId: newFolder.envId, + version: newFolder.version, + folderId: newFolder.id + }, + tx + ); + + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Changed by external migration", + folderId: parentEnv.rootFolderId, + changes: [ + { + type: CommitType.ADD, + folderVersionId: newFolderVersion.id + } + ] + }, + tx + ); + originalToNewFolderId.set(folder.id, { folderId: newFolder.id, projectId: parentEnv.projectId @@ -772,6 +808,7 @@ export const importDataIntoInfisicalFn = async ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + folderCommitService, actor: { type: actor, actorId diff --git a/backend/src/services/external-migration/external-migration-queue.ts b/backend/src/services/external-migration/external-migration-queue.ts index 8aa46b94c..66f2c73e7 100644 --- a/backend/src/services/external-migration/external-migration-queue.ts +++ b/backend/src/services/external-migration/external-migration-queue.ts @@ -3,6 +3,7 @@ import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectServiceFactory } from "../project/project-service"; @@ -10,6 +11,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvServiceFactory } from "../project-env/project-env-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; @@ -36,6 +38,8 @@ export type TExternalMigrationQueueFactoryDep = { projectService: Pick; projectEnvService: Pick; secretV2BridgeService: Pick; + folderCommitService: Pick; + folderVersionDAL: Pick; resourceMetadataDAL: Pick; }; @@ -56,6 +60,8 @@ export const externalMigrationQueueFactory = ({ secretTagDAL, secretVersionTagDAL, folderDAL, + folderCommitService, + folderVersionDAL, resourceMetadataDAL }: TExternalMigrationQueueFactoryDep) => { const startImport = async (dto: { @@ -114,6 +120,8 @@ export const externalMigrationQueueFactory = ({ projectService, projectEnvService, secretV2BridgeService, + folderCommitService, + folderVersionDAL, resourceMetadataDAL }); diff --git a/backend/src/services/folder-checkpoint-resources/folder-checkpoint-resources-dal.ts b/backend/src/services/folder-checkpoint-resources/folder-checkpoint-resources-dal.ts new file mode 100644 index 000000000..3e2d09d0b --- /dev/null +++ b/backend/src/services/folder-checkpoint-resources/folder-checkpoint-resources-dal.ts @@ -0,0 +1,118 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + TableName, + TFolderCheckpointResources, + TFolderCheckpoints, + TSecretFolderVersions, + TSecretVersionsV2 +} from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderCheckpointResourcesDALFactory = ReturnType; + +export type ResourceWithCheckpointInfo = TFolderCheckpointResources & { + folderCommitId: string; +}; + +export const folderCheckpointResourcesDALFactory = (db: TDbClient) => { + const folderCheckpointResourcesOrm = ormify(db, TableName.FolderCheckpointResources); + + const findByCheckpointId = async ( + folderCheckpointId: string, + tx?: Knex + ): Promise< + (TFolderCheckpointResources & { + referencedSecretId?: string; + referencedFolderId?: string; + folderName?: string; + folderVersion?: string; + secretKey?: string; + secretVersion?: string; + })[] + > => { + try { + const docs = await (tx || db.replicaNode())(TableName.FolderCheckpointResources) + .where({ folderCheckpointId }) + .leftJoin( + TableName.SecretVersionV2, + `${TableName.FolderCheckpointResources}.secretVersionId`, + `${TableName.SecretVersionV2}.id` + ) + .leftJoin( + TableName.SecretFolderVersion, + `${TableName.FolderCheckpointResources}.folderVersionId`, + `${TableName.SecretFolderVersion}.id` + ) + .select(selectAllTableCols(TableName.FolderCheckpointResources)) + .select( + db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"), + db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId"), + db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"), + db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"), + db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion") + ); + return docs.map((doc) => ({ + ...doc, + folderVersion: doc.folderVersion?.toString(), + secretVersion: doc.secretVersion?.toString() + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByCheckpointId" }); + } + }; + + const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise => { + try { + const docs = await (tx || db.replicaNode())< + TFolderCheckpointResources & Pick + >(TableName.FolderCheckpointResources) + .where({ secretVersionId }) + .select(selectAllTableCols(TableName.FolderCheckpointResources)) + .join( + TableName.FolderCheckpoint, + `${TableName.FolderCheckpointResources}.folderCheckpointId`, + `${TableName.FolderCheckpoint}.id` + ) + .select( + db.ref("folderCommitId").withSchema(TableName.FolderCheckpoint), + db.ref("createdAt").withSchema(TableName.FolderCheckpoint) + ); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindBySecretVersionId" }); + } + }; + + const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise => { + try { + const docs = await (tx || db.replicaNode())< + TFolderCheckpointResources & Pick + >(TableName.FolderCheckpointResources) + .where({ folderVersionId }) + .select(selectAllTableCols(TableName.FolderCheckpointResources)) + .join( + TableName.FolderCheckpoint, + `${TableName.FolderCheckpointResources}.folderCheckpointId`, + `${TableName.FolderCheckpoint}.id` + ) + .select( + db.ref("folderCommitId").withSchema(TableName.FolderCheckpoint), + db.ref("createdAt").withSchema(TableName.FolderCheckpoint) + ); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByFolderVersionId" }); + } + }; + + return { + ...folderCheckpointResourcesOrm, + findByCheckpointId, + findBySecretVersionId, + findByFolderVersionId + }; +}; diff --git a/backend/src/services/folder-checkpoint/folder-checkpoint-dal.ts b/backend/src/services/folder-checkpoint/folder-checkpoint-dal.ts new file mode 100644 index 000000000..51bba9cc0 --- /dev/null +++ b/backend/src/services/folder-checkpoint/folder-checkpoint-dal.ts @@ -0,0 +1,129 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TFolderCheckpoints, TFolderCommits } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderCheckpointDALFactory = ReturnType; + +type CheckpointWithCommitInfo = TFolderCheckpoints & { + actorMetadata: unknown; + actorType: string; + message?: string | null; + commitDate: Date; + folderId: string; +}; + +export const folderCheckpointDALFactory = (db: TDbClient) => { + const folderCheckpointOrm = ormify(db, TableName.FolderCheckpoint); + + const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCheckpoint) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderCommitId }, TableName.FolderCheckpoint)) + .select(selectAllTableCols(TableName.FolderCheckpoint)) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindByCommitId" }); + } + }; + + const findByFolderId = async (folderId: string, limit?: number, tx?: Knex): Promise => { + try { + let query = (tx || db.replicaNode())(TableName.FolderCheckpoint) + .join( + TableName.FolderCommit, + `${TableName.FolderCheckpoint}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderId }, TableName.FolderCommit)) + .select(selectAllTableCols(TableName.FolderCheckpoint)) + .select( + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"), + db.ref("folderId").withSchema(TableName.FolderCommit) + ) + .orderBy(`${TableName.FolderCheckpoint}.createdAt`, "desc"); + + if (limit !== undefined) { + query = query.limit(limit); + } + + return await query; + } catch (error) { + throw new DatabaseError({ error, name: "FindByFolderId" }); + } + }; + + const findLatestByFolderId = async (folderId: string, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCheckpoint) + .join( + TableName.FolderCommit, + `${TableName.FolderCheckpoint}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderId }, TableName.FolderCommit)) + .select(selectAllTableCols(TableName.FolderCheckpoint)) + .select( + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"), + db.ref("folderId").withSchema(TableName.FolderCommit) + ) + .orderBy(`${TableName.FolderCheckpoint}.createdAt`, "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestByFolderId" }); + } + }; + + const findNearestCheckpoint = async ( + folderCommitId: bigint, + folderId: string, + tx?: Knex + ): Promise<(CheckpointWithCommitInfo & { commitId: bigint }) | undefined> => { + try { + // Get the checkpoint with the highest commitId that's still less than or equal to our commit + const nearestCheckpoint = await (tx || db.replicaNode())(TableName.FolderCheckpoint) + .join( + TableName.FolderCommit, + `${TableName.FolderCheckpoint}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + .where(`${TableName.FolderCommit}.folderId`, "=", folderId) + .where(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString()) + .select(selectAllTableCols(TableName.FolderCheckpoint)) + .select( + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("commitId").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit).as("commitDate"), + db.ref("folderId").withSchema(TableName.FolderCommit) + ) + .orderBy(`${TableName.FolderCommit}.commitId`, "desc") + .first(); + return nearestCheckpoint; + } catch (error) { + throw new DatabaseError({ error, name: "FindNearestCheckpoint" }); + } + }; + + return { + ...folderCheckpointOrm, + findByCommitId, + findByFolderId, + findLatestByFolderId, + findNearestCheckpoint + }; +}; diff --git a/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts b/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts new file mode 100644 index 000000000..2c30c5bcf --- /dev/null +++ b/backend/src/services/folder-commit-changes/folder-commit-changes-dal.ts @@ -0,0 +1,233 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + TableName, + TFolderCommitChanges, + TFolderCommits, + TProjectEnvironments, + TSecretFolderVersions, + TSecretVersionsV2 +} from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderCommitChangesDALFactory = ReturnType; + +// Base type with common fields +type BaseCommitChangeInfo = TFolderCommitChanges & { + actorMetadata: unknown; + actorType: string; + message?: string | null; + folderId: string; + createdAt: Date; +}; + +// Secret-specific change +export type SecretCommitChange = BaseCommitChangeInfo & { + resourceType: "secret"; + secretKey: string; + changeType: string; + secretVersionId?: string | null; + secretVersion: string; + secretId: string; + versions?: { + secretKey: string; + secretComment: string; + skipMultilineEncoding?: boolean | null; + secretReminderRepeatDays?: number | null; + secretReminderNote?: string | null; + metadata?: unknown; + tags?: string[] | null; + secretReminderRecipients?: string[] | null; + secretValue: string; + }[]; +}; + +// Folder-specific change +export type FolderCommitChange = BaseCommitChangeInfo & { + resourceType: "folder"; + folderName: string; + folderVersion: string; + folderChangeId: string; + versions?: { + version: string; + name?: string; + }[]; +}; + +// Discriminated union +export type CommitChangeWithCommitInfo = SecretCommitChange | FolderCommitChange; + +// Type guards +export const isSecretCommitChange = (change: CommitChangeWithCommitInfo): change is SecretCommitChange => + change.resourceType === "secret"; + +export const isFolderCommitChange = (change: CommitChangeWithCommitInfo): change is FolderCommitChange => + change.resourceType === "folder"; + +export const folderCommitChangesDALFactory = (db: TDbClient) => { + const folderCommitChangesOrm = ormify(db, TableName.FolderCommitChanges); + + const findByCommitId = async ( + folderCommitId: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const docs = await (tx || db.replicaNode())(TableName.FolderCommitChanges) + .where(buildFindFilter({ folderCommitId }, TableName.FolderCommitChanges)) + .leftJoin( + TableName.FolderCommit, + `${TableName.FolderCommitChanges}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + .leftJoin( + TableName.SecretVersionV2, + `${TableName.FolderCommitChanges}.secretVersionId`, + `${TableName.SecretVersionV2}.id` + ) + .leftJoin( + TableName.SecretFolderVersion, + `${TableName.FolderCommitChanges}.folderVersionId`, + `${TableName.SecretFolderVersion}.id` + ) + .leftJoin( + TableName.Environment, + `${TableName.FolderCommit}.envId`, + `${TableName.Environment}.id` + ) + .where((qb) => { + if (projectId) { + void qb.where(`${TableName.Environment}.projectId`, "=", projectId); + } + }) + .select(selectAllTableCols(TableName.FolderCommitChanges)) + .select( + db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"), + db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderChangeId"), + db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"), + db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion"), + db.ref("secretId").withSchema(TableName.SecretVersionV2), + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit), + db.ref("folderId").withSchema(TableName.FolderCommit) + ); + + return docs.map((doc) => { + // Determine if this is a secret or folder change based on populated fields + if (doc.secretKey && doc.secretVersion !== null && doc.secretId) { + return { + ...doc, + resourceType: "secret", + secretKey: doc.secretKey, + secretVersion: doc.secretVersion.toString(), + secretId: doc.secretId + } as SecretCommitChange; + } + return { + ...doc, + resourceType: "folder", + folderName: doc.folderName, + folderVersion: doc.folderVersion.toString(), + folderChangeId: doc.folderChangeId + } as FolderCommitChange; + }); + } catch (error) { + throw new DatabaseError({ error, name: "FindByCommitId" }); + } + }; + + const findBySecretVersionId = async (secretVersionId: string, tx?: Knex): Promise => { + try { + const docs = await (tx || db.replicaNode())< + TFolderCommitChanges & + Pick + >(TableName.FolderCommitChanges) + .where(buildFindFilter({ secretVersionId }, TableName.FolderCommitChanges)) + .select(selectAllTableCols(TableName.FolderCommitChanges)) + .join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`) + .leftJoin( + TableName.SecretVersionV2, + `${TableName.FolderCommitChanges}.secretVersionId`, + `${TableName.SecretVersionV2}.id` + ) + .select( + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit), + db.ref("folderId").withSchema(TableName.FolderCommit), + db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion"), + db.ref("secretId").withSchema(TableName.SecretVersionV2) + ); + + return docs + .filter((doc) => doc.secretKey && doc.secretVersion !== null && doc.secretId) + .map( + (doc): SecretCommitChange => ({ + ...doc, + resourceType: "secret", + secretKey: doc.secretKey, + secretVersion: doc.secretVersion.toString(), + secretId: doc.secretId + }) + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindBySecretVersionId" }); + } + }; + + const findByFolderVersionId = async (folderVersionId: string, tx?: Knex): Promise => { + try { + const docs = await (tx || db.replicaNode())< + TFolderCommitChanges & + Pick + >(TableName.FolderCommitChanges) + .where(buildFindFilter({ folderVersionId }, TableName.FolderCommitChanges)) + .select(selectAllTableCols(TableName.FolderCommitChanges)) + .join(TableName.FolderCommit, `${TableName.FolderCommitChanges}.folderCommitId`, `${TableName.FolderCommit}.id`) + .leftJoin( + TableName.SecretFolderVersion, + `${TableName.FolderCommitChanges}.folderVersionId`, + `${TableName.SecretFolderVersion}.id` + ) + .select( + db.ref("actorMetadata").withSchema(TableName.FolderCommit), + db.ref("actorType").withSchema(TableName.FolderCommit), + db.ref("message").withSchema(TableName.FolderCommit), + db.ref("createdAt").withSchema(TableName.FolderCommit), + db.ref("folderId").withSchema(TableName.FolderCommit), + db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"), + db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("folderChangeId"), + db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion") + ); + + return docs + .filter((doc) => doc.folderName && doc.folderVersion !== null && doc.folderChangeId) + .map( + (doc): FolderCommitChange => ({ + ...doc, + resourceType: "folder", + folderName: doc.folderName, + folderVersion: doc.folderVersion!.toString(), + folderChangeId: doc.folderChangeId + }) + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindByFolderVersionId" }); + } + }; + + return { + ...folderCommitChangesOrm, + findByCommitId, + findBySecretVersionId, + findByFolderVersionId + }; +}; diff --git a/backend/src/services/folder-commit/folder-commit-dal.ts b/backend/src/services/folder-commit/folder-commit-dal.ts new file mode 100644 index 000000000..e95dbb839 --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-dal.ts @@ -0,0 +1,513 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + TableName, + TFolderCommitChanges, + TFolderCommits, + TProjectEnvironments, + TSecretFolderVersions, + TSecretVersionsV2 +} from "@app/db/schemas"; +import { DatabaseError, NotFoundError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderCommitDALFactory = ReturnType; + +export const folderCommitDALFactory = (db: TDbClient) => { + const folderCommitOrm = ormify(db, TableName.FolderCommit); + const { delete: deleteOp, deleteById, ...restOfOrm } = folderCommitOrm; + + const findByFolderId = async (folderId: string, tx?: Knex): Promise => { + try { + const trx = tx || db.replicaNode(); + + // First, get all folder commits + const folderCommits = await trx(TableName.FolderCommit) + .where({ folderId }) + .select("*") + .orderBy("createdAt", "desc"); + + if (folderCommits.length === 0) return []; + + // Get all commit IDs + const commitIds = folderCommits.map((commit) => commit.id); + + // Then get all related changes + const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*"); + + const changesMap = changes.reduce( + (acc, change) => { + const { folderCommitId } = change; + if (!acc[folderCommitId]) acc[folderCommitId] = []; + acc[folderCommitId].push(change); + return acc; + }, + {} as Record + ); + + return folderCommits.map((commit) => ({ + ...commit, + changes: changesMap[commit.id] || [] + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByFolderId" }); + } + }; + + const findLatestCommit = async ( + folderId: string, + projectId?: string, + tx?: Knex + ): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ folderId }) + .leftJoin(TableName.Environment, `${TableName.FolderCommit}.envId`, `${TableName.Environment}.id`) + .where((qb) => { + if (projectId) { + void qb.where(`${TableName.Environment}.projectId`, "=", projectId); + } + }) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommit" }); + } + }; + + const findLatestCommitByFolderIds = async (folderIds: string[], tx?: Knex): Promise => { + try { + // First get max commitId for each folderId + const maxCommitIdSubquery = (tx || db.replicaNode())(TableName.FolderCommit) + .select("folderId") + .max("commitId as maxCommitId") + .whereIn("folderId", folderIds) + .groupBy("folderId"); + + // Join with main table to get complete records for each max commitId + const docs = await (tx || db.replicaNode())(TableName.FolderCommit) + .select(selectAllTableCols(TableName.FolderCommit)) + // eslint-disable-next-line func-names + .join(maxCommitIdSubquery.as("latest"), function () { + this.on(`${TableName.FolderCommit}.folderId`, "=", "latest.folderId").andOn( + `${TableName.FolderCommit}.commitId`, + "=", + "latest.maxCommitId" + ); + }); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommitByFolderIds" }); + } + }; + + const findLatestEnvCommit = async (envId: string, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where(`${TableName.FolderCommit}.envId`, "=", envId) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommit" }); + } + }; + + const findMultipleLatestCommits = async (folderIds: string[], tx?: Knex): Promise => { + try { + const knexInstance = tx || db.replicaNode(); + + // Get the latest commitId for each folderId + const subquery = knexInstance(TableName.FolderCommit) + .whereIn("folderId", folderIds) + .groupBy("folderId") + .select("folderId") + .max("commitId as maxCommitId"); + + // Then fetch the complete rows matching those latest commits + const docs = await knexInstance(TableName.FolderCommit) + // eslint-disable-next-line func-names + .innerJoin(subquery.as("latest"), function () { + this.on(`${TableName.FolderCommit}.folderId`, "=", "latest.folderId").andOn( + `${TableName.FolderCommit}.commitId`, + "=", + "latest.maxCommitId" + ); + }) + .select(selectAllTableCols(TableName.FolderCommit)); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindMultipleLatestCommits" }); + } + }; + + const getNumberOfCommitsSince = async (folderId: string, folderCommitId: string, tx?: Knex): Promise => { + try { + const referencedCommit = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ id: folderCommitId }) + .select("commitId") + .first(); + + if (referencedCommit?.commitId) { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ folderId }) + .where("commitId", ">", referencedCommit.commitId) + .count(); + return Number(doc?.[0].count); + } + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "getNumberOfCommitsSince" }); + } + }; + + const getEnvNumberOfCommitsSince = async (envId: string, folderCommitId: string, tx?: Knex): Promise => { + try { + const referencedCommit = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ id: folderCommitId }) + .select("commitId") + .first(); + + if (referencedCommit?.commitId) { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where(`${TableName.FolderCommit}.envId`, "=", envId) + .where("commitId", ">", referencedCommit.commitId) + .count(); + return Number(doc?.[0].count); + } + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "getNumberOfCommitsSince" }); + } + }; + + const findCommitsToRecreate = async ( + folderId: string, + targetCommitNumber: bigint, + checkpointCommitNumber: bigint, + tx?: Knex + ): Promise< + (TFolderCommits & { + changes: (TFolderCommitChanges & { + referencedSecretId?: string; + referencedFolderId?: string; + folderName?: string; + folderVersion?: string; + secretKey?: string; + secretVersion?: string; + })[]; + })[] + > => { + try { + // First get all the commits in the range + const commits = await (tx || db.replicaNode())(TableName.FolderCommit) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderId }, TableName.FolderCommit)) + .andWhere(`${TableName.FolderCommit}.commitId`, ">", checkpointCommitNumber.toString()) + .andWhere(`${TableName.FolderCommit}.commitId`, "<=", targetCommitNumber.toString()) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy(`${TableName.FolderCommit}.commitId`, "asc"); + + // If no commits found, return empty array + if (!commits.length) { + return []; + } + + // Get all the commit IDs + const commitIds = commits.map((commit) => commit.id); + + // Get all changes for these commits in a single query + const allChanges = await (tx || db.replicaNode())(TableName.FolderCommitChanges) + .whereIn(`${TableName.FolderCommitChanges}.folderCommitId`, commitIds) + .leftJoin( + TableName.SecretVersionV2, + `${TableName.FolderCommitChanges}.secretVersionId`, + `${TableName.SecretVersionV2}.id` + ) + .leftJoin( + TableName.SecretFolderVersion, + `${TableName.FolderCommitChanges}.folderVersionId`, + `${TableName.SecretFolderVersion}.id` + ) + .select(selectAllTableCols(TableName.FolderCommitChanges)) + .select( + db.ref("secretId").withSchema(TableName.SecretVersionV2).as("referencedSecretId"), + db.ref("folderId").withSchema(TableName.SecretFolderVersion).as("referencedFolderId"), + db.ref("name").withSchema(TableName.SecretFolderVersion).as("folderName"), + db.ref("version").withSchema(TableName.SecretFolderVersion).as("folderVersion"), + db.ref("key").withSchema(TableName.SecretVersionV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretVersionV2).as("secretVersion") + ); + + // Organize changes by commit ID + const changesByCommitId = allChanges.reduce( + (acc, change) => { + if (!acc[change.folderCommitId]) { + acc[change.folderCommitId] = []; + } + acc[change.folderCommitId].push(change); + return acc; + }, + {} as Record + ); + + // Attach changes to each commit + return commits.map((commit) => ({ + ...commit, + changes: changesByCommitId[commit.id] || [] + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindCommitsToRecreate" }); + } + }; + + const findLatestCommitBetween = async ({ + folderId, + startCommitId, + endCommitId, + tx + }: { + folderId: string; + startCommitId?: string; + endCommitId: string; + tx?: Knex; + }): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where("commitId", "<=", endCommitId) + .where({ folderId }) + .where((qb) => { + if (startCommitId) { + void qb.where("commitId", ">=", startCommitId); + } + }) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommitBetween" }); + } + }; + + const findAllCommitsBetween = async ({ + envId, + startCommitId, + endCommitId, + tx + }: { + envId?: string; + startCommitId?: string; + endCommitId?: string; + tx?: Knex; + }): Promise => { + try { + const docs = await (tx || db.replicaNode())(TableName.FolderCommit) + .where((qb) => { + if (envId) { + void qb.where(`${TableName.FolderCommit}.envId`, "=", envId); + } + if (startCommitId) { + void qb.where("commitId", ">=", startCommitId); + } + if (endCommitId) { + void qb.where("commitId", "<=", endCommitId); + } + }) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommitBetween" }); + } + }; + + const findAllFolderCommitsAfter = async ({ + envId, + startCommitId, + tx + }: { + envId?: string; + startCommitId?: string; + tx?: Knex; + }): Promise => { + try { + const docs = await (tx || db.replicaNode())(TableName.FolderCommit) + .where((qb) => { + if (envId) { + void qb.where(`${TableName.FolderCommit}.envId`, "=", envId); + } + if (startCommitId) { + void qb.where("commitId", ">=", startCommitId); + } + }) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc"); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestCommitBetween" }); + } + }; + + const findPreviousCommitTo = async ( + folderId: string, + commitId: string, + tx?: Knex + ): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ folderId }) + .where("commitId", "<=", commitId) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindPreviousCommitTo" }); + } + }; + + const findById = async (id: string, tx?: Knex, projectId?: string): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ id }, TableName.FolderCommit)) + .leftJoin( + TableName.Environment, + `${TableName.FolderCommit}.envId`, + `${TableName.Environment}.id` + ) + .where((qb) => { + if (projectId) { + void qb.where(`${TableName.Environment}.projectId`, "=", projectId); + } + }) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + if (!doc) { + throw new NotFoundError({ + message: `Folder commit not found for ID ${id}` + }); + } + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + const findByFolderIdPaginated = async ( + folderId: string, + options: { + offset?: number; + limit?: number; + search?: string; + sort?: "asc" | "desc"; + } = {}, + tx?: Knex + ): Promise<{ + commits: TFolderCommits[]; + total: number; + hasMore: boolean; + }> => { + try { + const { offset = 0, limit = 20, search, sort = "desc" } = options; + const trx = tx || db.replicaNode(); + + // Build base query + let baseQuery = trx(TableName.FolderCommit).where({ folderId }); + + // Add search functionality + if (search) { + baseQuery = baseQuery.where((qb) => { + void qb.whereILike("message", `%${search}%`); + }); + } + + // Get total count + const totalResult = await baseQuery.clone().count("*", { as: "count" }).first(); + const total = Number(totalResult?.count || 0); + + // Get paginated commits + const folderCommits = await baseQuery.select("*").orderBy("createdAt", sort).limit(limit).offset(offset); + + if (folderCommits.length === 0) { + return { commits: [], total, hasMore: false }; + } + + // Get all commit IDs for changes + const commitIds = folderCommits.map((commit) => commit.id); + + // Get all related changes + const changes = await trx(TableName.FolderCommitChanges).whereIn("folderCommitId", commitIds).select("*"); + + const changesMap = changes.reduce( + (acc, change) => { + const { folderCommitId } = change; + if (!acc[folderCommitId]) acc[folderCommitId] = []; + acc[folderCommitId].push(change); + return acc; + }, + {} as Record + ); + + const commitsWithChanges = folderCommits.map((commit) => ({ + ...commit, + changes: changesMap[commit.id] || [] + })); + + const hasMore = offset + limit < total; + + return { + commits: commitsWithChanges, + total, + hasMore + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByFolderIdPaginated" }); + } + }; + + const findCommitBefore = async ( + folderId: string, + commitId: bigint, + tx?: Knex + ): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderCommit) + .where({ folderId }) + .where("commitId", "<", commitId.toString()) + .select(selectAllTableCols(TableName.FolderCommit)) + .orderBy("commitId", "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindCommitBefore" }); + } + }; + + return { + ...restOfOrm, + findByFolderId, + findLatestCommit, + getNumberOfCommitsSince, + findCommitsToRecreate, + findMultipleLatestCommits, + findAllCommitsBetween, + findLatestCommitBetween, + findLatestEnvCommit, + getEnvNumberOfCommitsSince, + findLatestCommitByFolderIds, + findAllFolderCommitsAfter, + findPreviousCommitTo, + findById, + findByFolderIdPaginated, + findCommitBefore + }; +}; diff --git a/backend/src/services/folder-commit/folder-commit-queue.ts b/backend/src/services/folder-commit/folder-commit-queue.ts new file mode 100644 index 000000000..fcb348784 --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-queue.ts @@ -0,0 +1,282 @@ +import { Knex } from "knex"; + +import { TSecretFolders } from "@app/db/schemas"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TFolderTreeCheckpointDALFactory } from "../folder-tree-checkpoint/folder-tree-checkpoint-dal"; +import { TFolderTreeCheckpointResourcesDALFactory } from "../folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TFolderCommitDALFactory } from "./folder-commit-dal"; + +// Define types for job data +type TCreateFolderTreeCheckpointDTO = { + envId: string; + failedToAcquireLockCount?: number; + folderCommitId?: string; +}; + +type TFolderCommitQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + keyStore: Pick; + folderTreeCheckpointDAL: Pick< + TFolderTreeCheckpointDALFactory, + "create" | "findLatestByEnvId" | "findNearestCheckpoint" + >; + folderTreeCheckpointResourcesDAL: Pick< + TFolderTreeCheckpointResourcesDALFactory, + "insertMany" | "findByTreeCheckpointId" + >; + folderCommitDAL: Pick< + TFolderCommitDALFactory, + "findLatestEnvCommit" | "getEnvNumberOfCommitsSince" | "findMultipleLatestCommits" | "findById" + >; + folderDAL: Pick; +}; + +export type TFolderCommitQueueServiceFactory = ReturnType; + +export const folderCommitQueueServiceFactory = ({ + queueService, + keyStore, + folderTreeCheckpointDAL, + folderTreeCheckpointResourcesDAL, + folderCommitDAL, + folderDAL +}: TFolderCommitQueueServiceFactoryDep) => { + const appCfg = getConfig(); + + // Helper function to calculate delay for requeuing + const getRequeueDelay = (failureCount?: number) => { + if (!failureCount) return 0; + + const baseDelay = 5000; + const maxDelay = 30000; + + const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay); + const jitter = delay * (0.5 + Math.random() * 0.5); + + return jitter; + }; + + const scheduleTreeCheckpoint = async (payload: TCreateFolderTreeCheckpointDTO) => { + const { envId, failedToAcquireLockCount = 0 } = payload; + + // Create a unique jobId for each retry to prevent conflicts + const jobId = + failedToAcquireLockCount > 0 ? `${envId}-retry-${failedToAcquireLockCount}-${Date.now()}` : `${envId}`; + + await queueService.queue(QueueName.FolderTreeCheckpoint, QueueJobs.CreateFolderTreeCheckpoint, payload, { + jobId, + delay: getRequeueDelay(failedToAcquireLockCount), + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + }); + }; + + // Sort folders by hierarchy (copied from the source code) + const sortFoldersByHierarchy = (folders: TSecretFolders[]) => { + const childrenMap = new Map(); + const allFolderIds = new Set(); + + folders.forEach((folder) => { + if (folder.id) allFolderIds.add(folder.id); + }); + + folders.forEach((folder) => { + if (folder.parentId) { + const children = childrenMap.get(folder.parentId) || []; + children.push(folder); + childrenMap.set(folder.parentId, children); + } + }); + + const rootFolders = folders.filter((folder) => !folder.parentId || !allFolderIds.has(folder.parentId)); + + const result = []; + let currentLevel = rootFolders; + + while (currentLevel.length > 0) { + result.push(...currentLevel); + + const nextLevel = []; + for (const folder of currentLevel) { + if (folder.id) { + const children = childrenMap.get(folder.id) || []; + nextLevel.push(...children); + } + } + + currentLevel = nextLevel; + } + + return result; + }; + + const createFolderTreeCheckpoint = async (jobData: TCreateFolderTreeCheckpointDTO, tx?: Knex) => { + const { envId, folderCommitId, failedToAcquireLockCount = 0 } = jobData; + + logger.info(`Folder tree checkpoint creation started [envId=${envId}] [attempt=${failedToAcquireLockCount + 1}]`); + + // First, try to clear any stale locks before attempting to acquire + if (failedToAcquireLockCount > 1) { + try { + await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId)); + logger.info(`Cleared potential stale lock for envId ${envId} before attempt ${failedToAcquireLockCount + 1}`); + } catch (error) { + // This is fine if it fails, we'll still try to acquire the lock + logger.info(`No stale lock found for envId ${envId}`); + } + } + + let lock: Awaited> | undefined; + + try { + // Attempt to acquire the lock with a shorter timeout for first attempts + const timeout = failedToAcquireLockCount > 3 ? 60 * 1000 : 15 * 1000; + + logger.info(`Attempting to acquire lock for envId=${envId} with timeout ${timeout}ms`); + + lock = await keyStore.acquireLock([KeyStorePrefixes.FolderTreeCheckpoint(envId)], timeout); + + logger.info(`Successfully acquired lock for envId=${envId}`); + } catch (e) { + logger.info( + `Failed to acquire lock for folder tree checkpoint [envId=${envId}] [attempt=${failedToAcquireLockCount + 1}]` + ); + + // Requeue with incremented failure count if under max attempts + if (failedToAcquireLockCount < 10) { + // Force a delay between retries + const nextRetryCount = failedToAcquireLockCount + 1; + + logger.info(`Scheduling retry #${nextRetryCount} for folder tree checkpoint [envId=${envId}]`); + + // Create a new job with incremented counter + await scheduleTreeCheckpoint({ + envId, + folderCommitId, + failedToAcquireLockCount: nextRetryCount + }); + } else { + // Max retries reached + logger.error(`Maximum lock acquisition attempts (10) reached for envId ${envId}. Giving up.`); + // Try to force-clear the lock for next time + try { + await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId)); + } catch (clearError) { + logger.error(clearError, `Failed to clear lock after maximum retries for envId=${envId}`); + } + } + return; + } + + if (!lock) { + logger.error(`Lock is undefined after acquisition for envId=${envId}. This should never happen.`); + return; + } + + try { + logger.info(`Processing tree checkpoint data for envId=${envId}`); + + const latestTreeCheckpoint = await folderTreeCheckpointDAL.findLatestByEnvId(envId, tx); + + let latestCommit; + if (folderCommitId) { + latestCommit = await folderCommitDAL.findById(folderCommitId, tx); + } else { + latestCommit = await folderCommitDAL.findLatestEnvCommit(envId, tx); + } + if (!latestCommit) { + logger.info(`Latest commit ID not found for envId ${envId}`); + return; + } + const latestCommitId = latestCommit.id; + + if (latestTreeCheckpoint) { + const commitsSinceLastCheckpoint = await folderCommitDAL.getEnvNumberOfCommitsSince( + envId, + latestTreeCheckpoint.folderCommitId, + tx + ); + if (commitsSinceLastCheckpoint < Number(appCfg.PIT_TREE_CHECKPOINT_WINDOW)) { + logger.info( + `Commits since last checkpoint ${commitsSinceLastCheckpoint} is less than ${appCfg.PIT_TREE_CHECKPOINT_WINDOW}` + ); + return; + } + } + + const folders = await folderDAL.findByEnvId(envId, tx); + const sortedFolders = sortFoldersByHierarchy(folders); + const filteredFoldersIds = sortedFolders.filter((folder) => !folder.isReserved).map((folder) => folder.id); + + const folderCommits = await folderCommitDAL.findMultipleLatestCommits(filteredFoldersIds, tx); + const folderTreeCheckpoint = await folderTreeCheckpointDAL.create( + { + folderCommitId: latestCommitId + }, + tx + ); + + await folderTreeCheckpointResourcesDAL.insertMany( + folderCommits.map((folderCommit) => ({ + folderTreeCheckpointId: folderTreeCheckpoint.id, + folderId: folderCommit.folderId, + folderCommitId: folderCommit.id + })), + tx + ); + + logger.info(`Folder tree checkpoint created successfully: ${folderTreeCheckpoint.id}`); + } catch (error) { + logger.error(error, `Error processing folder tree checkpoint [envId=${envId}]`); + throw error; + } finally { + // Always release the lock + try { + if (lock) { + await lock.release(); + logger.info(`Released lock for folder tree checkpoint [envId=${envId}]`); + } else { + logger.error(`No lock to release for envId=${envId}. This should never happen.`); + } + } catch (releaseError) { + logger.error(releaseError, `Error releasing lock for folder tree checkpoint [envId=${envId}]`); + // Try to force delete the lock if release fails + try { + await keyStore.deleteItem(KeyStorePrefixes.FolderTreeCheckpoint(envId)); + logger.info(`Force deleted lock after release failure for envId=${envId}`); + } catch (deleteError) { + logger.error(deleteError, `Failed to force delete lock after release failure for envId=${envId}`); + } + } + } + }; + + queueService.start(QueueName.FolderTreeCheckpoint, async (job) => { + try { + if (job.name === QueueJobs.CreateFolderTreeCheckpoint) { + const jobData = job.data as TCreateFolderTreeCheckpointDTO; + await createFolderTreeCheckpoint(jobData); + } + } catch (error) { + logger.error(error, "Error creating folder tree checkpoint:"); + throw error; + } + }); + + return { + scheduleTreeCheckpoint: (envId: string) => scheduleTreeCheckpoint({ envId }), + createFolderTreeCheckpoint: (envId: string, folderCommitId?: string, tx?: Knex) => + createFolderTreeCheckpoint({ envId, folderCommitId }, tx) + }; +}; diff --git a/backend/src/services/folder-commit/folder-commit-schemas.ts b/backend/src/services/folder-commit/folder-commit-schemas.ts new file mode 100644 index 000000000..9f99bd2cc --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-schemas.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; + +// Base schema shared by both secret and folder changes +const baseChangeSchema = z.object({ + id: z.string(), + folderCommitId: z.string(), + changeType: z.string(), + isUpdate: z.boolean().optional(), + createdAt: z.union([z.string(), z.date()]), + updatedAt: z.union([z.string(), z.date()]), + actorMetadata: z + .union([ + z.object({ + id: z.string().optional(), + name: z.string().optional() + }), + z.unknown() + ]) + .optional(), + actorType: z.string(), + message: z.string().nullable().optional(), + folderId: z.string() +}); + +// Secret-specific versions schema +const secretVersionSchema = z.object({ + secretKey: z.string(), + secretComment: z.string(), + skipMultilineEncoding: z.boolean().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + metadata: z.unknown().nullable().optional(), + secretValue: z.string() +}); + +// Folder-specific versions schema +const folderVersionSchema = z.object({ + version: z.string().optional(), + name: z.string().optional(), + description: z.string().optional().nullable() +}); + +// Secret commit change schema +const secretCommitChangeSchema = baseChangeSchema.extend({ + resourceType: z.literal("secret"), + secretVersionId: z.string().optional().nullable(), + secretKey: z.string(), + secretVersion: z.union([z.string(), z.number()]), + secretId: z.string(), + versions: z.array(secretVersionSchema).optional() +}); + +// Folder commit change schema +const folderCommitChangeSchema = baseChangeSchema.extend({ + resourceType: z.literal("folder"), + folderVersionId: z.string().optional().nullable(), + folderName: z.string(), + folderChangeId: z.string(), + folderVersion: z.union([z.string(), z.number()]), + versions: z.array(folderVersionSchema).optional() +}); + +// Discriminated union for commit changes +export const commitChangeSchema = z.discriminatedUnion("resourceType", [ + secretCommitChangeSchema, + folderCommitChangeSchema +]); + +// Commit schema +const commitSchema = z.object({ + id: z.string(), + commitId: z.string(), + actorMetadata: z + .union([ + z.object({ + id: z.string().optional(), + name: z.string().optional() + }), + z.unknown() + ]) + .optional(), + actorType: z.string(), + message: z.string().nullable().optional(), + folderId: z.string(), + envId: z.string(), + createdAt: z.union([z.string(), z.date()]), + updatedAt: z.union([z.string(), z.date()]), + isLatest: z.boolean().default(false), + changes: z.array(commitChangeSchema).optional() +}); + +// Response schema +export const commitChangesResponseSchema = z.object({ + changes: commitSchema +}); + +// Base resource change schema for comparison results +const baseResourceChangeSchema = z.object({ + id: z.string(), + versionId: z.string(), + oldVersionId: z.string().optional(), + changeType: z.enum(["add", "delete", "update", "create"]), + commitId: z.union([z.string(), z.bigint()]), + createdAt: z.union([z.string(), z.date()]).optional(), + parentId: z.string().optional(), + isUpdate: z.boolean().optional(), + fromVersion: z.union([z.string(), z.number()]).optional() +}); + +// Secret resource change schema +const secretResourceChangeSchema = baseResourceChangeSchema.extend({ + type: z.literal("secret"), + secretKey: z.string(), + secretVersion: z.union([z.string(), z.number()]), + secretId: z.string(), + versions: z + .array( + z.object({ + secretKey: z.string().optional(), + secretComment: z.string().optional(), + skipMultilineEncoding: z.boolean().nullable().optional(), + secretReminderRepeatDays: z.number().nullable().optional(), + tags: z.array(z.string()).nullable().optional(), + metadata: z.unknown().nullable().optional(), + secretReminderNote: z.string().nullable().optional(), + secretValue: z.string().optional() + }) + ) + .optional() +}); + +// Folder resource change schema +const folderResourceChangeSchema = baseResourceChangeSchema.extend({ + type: z.literal("folder"), + folderName: z.string(), + folderVersion: z.union([z.string(), z.number()]), + versions: z.array(folderVersionSchema).optional() +}); + +// Discriminated union for resource changes +export const resourceChangeSchema = z.discriminatedUnion("type", [ + secretResourceChangeSchema, + folderResourceChangeSchema +]); diff --git a/backend/src/services/folder-commit/folder-commit-service.test.ts b/backend/src/services/folder-commit/folder-commit-service.test.ts new file mode 100644 index 000000000..1879cf493 --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-service.test.ts @@ -0,0 +1,671 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import { Knex } from "knex"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ProjectType, TSecretFolderVersions, TSecretVersionsV2 } from "@app/db/schemas"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType } from "../auth/auth-type"; +import { + ChangeType, + CommitType, + folderCommitServiceFactory, + ResourceChange, + TFolderCommitServiceFactory +} from "./folder-commit-service"; + +// Mock config +vi.mock("@app/lib/config/env", () => ({ + getConfig: () => ({ + PIT_CHECKPOINT_WINDOW: 5, + PIT_TREE_CHECKPOINT_WINDOW: 10 + }) +})); + +// Mock logger +vi.mock("@app/lib/logger", () => ({ + logger: { + info: vi.fn(), + error: vi.fn() + } +})); + +describe("folderCommitServiceFactory", () => { + // Properly type the mock functions + type TransactionCallback = (trx: Knex) => Promise; + + // Mock dependencies + const mockFolderCommitDAL = { + create: vi.fn().mockResolvedValue({}), + findById: vi.fn().mockResolvedValue({}), + findByFolderId: vi.fn().mockResolvedValue([]), + findLatestCommit: vi.fn().mockResolvedValue({}), + transaction: vi.fn().mockImplementation((callback: TransactionCallback) => callback({} as Knex)), + getNumberOfCommitsSince: vi.fn().mockResolvedValue(0), + getEnvNumberOfCommitsSince: vi.fn().mockResolvedValue(0), + findCommitsToRecreate: vi.fn().mockResolvedValue([]), + findMultipleLatestCommits: vi.fn().mockResolvedValue([]), + findLatestCommitBetween: vi.fn().mockResolvedValue({}), + findAllCommitsBetween: vi.fn().mockResolvedValue([]), + findLatestEnvCommit: vi.fn().mockResolvedValue({}), + findLatestCommitByFolderIds: vi.fn().mockResolvedValue({}) + }; + + const mockKmsService = { + createCipherPairWithDataKey: vi.fn().mockResolvedValue({}) + }; + + const mockFolderCommitChangesDAL = { + create: vi.fn().mockResolvedValue({}), + findByCommitId: vi.fn().mockResolvedValue([]), + insertMany: vi.fn().mockResolvedValue([]) + }; + + const mockFolderCheckpointDAL = { + create: vi.fn().mockResolvedValue({}), + findByFolderId: vi.fn().mockResolvedValue([]), + findLatestByFolderId: vi.fn().mockResolvedValue(null), + findNearestCheckpoint: vi.fn().mockResolvedValue({}) + }; + + const mockFolderCheckpointResourcesDAL = { + insertMany: vi.fn().mockResolvedValue([]), + findByCheckpointId: vi.fn().mockResolvedValue([]) + }; + + const mockFolderTreeCheckpointDAL = { + create: vi.fn().mockResolvedValue({}), + findByProjectId: vi.fn().mockResolvedValue([]), + findLatestByProjectId: vi.fn().mockResolvedValue({}), + findNearestCheckpoint: vi.fn().mockResolvedValue({}), + findLatestByEnvId: vi.fn().mockResolvedValue({}) + }; + + const mockFolderTreeCheckpointResourcesDAL = { + insertMany: vi.fn().mockResolvedValue([]), + findByTreeCheckpointId: vi.fn().mockResolvedValue([]) + }; + + const mockUserDAL = { + findById: vi.fn().mockResolvedValue({}) + }; + + const mockIdentityDAL = { + findById: vi.fn().mockResolvedValue({}) + }; + + const mockFolderDAL = { + findByParentId: vi.fn().mockResolvedValue([]), + findByProjectId: vi.fn().mockResolvedValue([]), + deleteById: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + updateById: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + find: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue({}), + findByEnvId: vi.fn().mockResolvedValue([]), + findFoldersByRootAndIds: vi.fn().mockResolvedValue([]) + }; + + const mockFolderVersionDAL = { + findLatestFolderVersions: vi.fn().mockResolvedValue({}), + findById: vi.fn().mockResolvedValue({}), + deleteById: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + updateById: vi.fn().mockResolvedValue({}), + find: vi.fn().mockResolvedValue({}), // Changed from [] to {} to match Object.values() expectation + findByIdsWithLatestVersion: vi.fn().mockResolvedValue({}) + }; + + const mockSecretVersionV2BridgeDAL = { + findLatestVersionByFolderId: vi.fn().mockResolvedValue([]), + findById: vi.fn().mockResolvedValue({}), + deleteById: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + updateById: vi.fn().mockResolvedValue({}), + find: vi.fn().mockResolvedValue([]), + findByIdsWithLatestVersion: vi.fn().mockResolvedValue({}), + findLatestVersionMany: vi.fn().mockResolvedValue({}) + }; + + const mockSecretV2BridgeDAL = { + deleteById: vi.fn().mockResolvedValue({}), + create: vi.fn().mockResolvedValue({}), + updateById: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + insertMany: vi.fn().mockResolvedValue([]), + invalidateSecretCacheByProjectId: vi.fn().mockResolvedValue({}) + }; + + const mockProjectDAL = { + findById: vi.fn().mockResolvedValue({}), + findProjectByEnvId: vi.fn().mockResolvedValue({}) + }; + + const mockFolderCommitQueueService = { + scheduleTreeCheckpoint: vi.fn().mockResolvedValue({}), + createFolderTreeCheckpoint: vi.fn().mockResolvedValue({}) + }; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({}) + }; + + const mockSecretTagDAL = { + findSecretTagsByVersionId: vi.fn().mockResolvedValue([]), + saveTagsToSecretV2: vi.fn().mockResolvedValue([]), + findSecretTagsBySecretId: vi.fn().mockResolvedValue([]), + deleteTagsToSecretV2: vi.fn().mockResolvedValue([]), + saveTagsToSecretVersionV2: vi.fn().mockResolvedValue([]) + }; + + const mockResourceMetadataDAL = { + find: vi.fn().mockResolvedValue([]), + insertMany: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue([]) + }; + + let folderCommitService: TFolderCommitServiceFactory; + + beforeEach(() => { + vi.clearAllMocks(); + + folderCommitService = folderCommitServiceFactory({ + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderCommitDAL: mockFolderCommitDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderCommitChangesDAL: mockFolderCommitChangesDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderCheckpointDAL: mockFolderCheckpointDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderCheckpointResourcesDAL: mockFolderCheckpointResourcesDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderTreeCheckpointDAL: mockFolderTreeCheckpointDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderTreeCheckpointResourcesDAL: mockFolderTreeCheckpointResourcesDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + userDAL: mockUserDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + identityDAL: mockIdentityDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderDAL: mockFolderDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + folderVersionDAL: mockFolderVersionDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + secretVersionV2BridgeDAL: mockSecretVersionV2BridgeDAL, + projectDAL: mockProjectDAL, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + secretV2BridgeDAL: mockSecretV2BridgeDAL, + folderCommitQueueService: mockFolderCommitQueueService, + // @ts-expect-error - Mock implementation doesn't need all interface methods for testing + permissionService: mockPermissionService, + kmsService: mockKmsService, + secretTagDAL: mockSecretTagDAL, + resourceMetadataDAL: mockResourceMetadataDAL + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createCommit", () => { + it("should successfully create a commit with user actor", async () => { + // Arrange + const userData = { id: "user-id", username: "testuser" }; + const folderData = { id: "folder-id", envId: "env-id" }; + const commitData = { id: "commit-id", folderId: "folder-id" }; + + mockUserDAL.findById.mockResolvedValue(userData); + mockFolderDAL.findById.mockResolvedValue(folderData); + mockFolderCommitDAL.create.mockResolvedValue(commitData); + mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null); + mockFolderCommitDAL.findLatestCommit.mockResolvedValue({ id: "latest-commit-id" }); + mockFolderDAL.findByParentId.mockResolvedValue([]); + mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]); + + const data = { + actor: { + type: ActorType.USER, + metadata: { id: userData.id } + }, + message: "Test commit", + folderId: folderData.id, + changes: [ + { + type: CommitType.ADD, + secretVersionId: "secret-version-1" + } + ] + }; + + // Act + const result = await folderCommitService.createCommit(data); + + // Assert + expect(mockUserDAL.findById).toHaveBeenCalledWith(userData.id, undefined); + expect(mockFolderDAL.findById).toHaveBeenCalledWith(folderData.id, undefined); + expect(mockFolderCommitDAL.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorType: ActorType.USER, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + actorMetadata: expect.objectContaining({ name: userData.username }), + message: data.message, + folderId: data.folderId, + envId: folderData.envId + }), + undefined + ); + expect(mockFolderCommitChangesDAL.insertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + folderCommitId: commitData.id, + changeType: data.changes[0].type, + secretVersionId: data.changes[0].secretVersionId + }) + ]), + undefined + ); + expect(mockFolderCommitQueueService.scheduleTreeCheckpoint).toHaveBeenCalledWith(folderData.envId); + expect(result).toEqual(commitData); + }); + + it("should successfully create a commit with identity actor", async () => { + // Arrange + const identityData = { id: "identity-id", name: "testidentity" }; + const folderData = { id: "folder-id", envId: "env-id" }; + const commitData = { id: "commit-id", folderId: "folder-id" }; + + mockIdentityDAL.findById.mockResolvedValue(identityData); + mockFolderDAL.findById.mockResolvedValue(folderData); + mockFolderCommitDAL.create.mockResolvedValue(commitData); + mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null); + mockFolderCommitDAL.findLatestCommit.mockResolvedValue({ id: "latest-commit-id" }); + mockFolderDAL.findByParentId.mockResolvedValue([]); + mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]); + + // Mock folderVersionDAL.find to return an object with folder version data + mockFolderVersionDAL.find.mockResolvedValue({ + "folder-version-1": { + id: "folder-version-1", + folderId: "sub-folder-id", + envId: "env-id", + name: "Test Folder", + version: 1 + } + }); + + const data = { + actor: { + type: ActorType.IDENTITY, + metadata: { id: identityData.id } + }, + message: "Test commit", + folderId: folderData.id, + changes: [ + { + type: CommitType.ADD, + folderVersionId: "folder-version-1" + } + ], + omitIgnoreFilter: true + }; + + // Act + const result = await folderCommitService.createCommit(data); + + // Assert + expect(mockIdentityDAL.findById).toHaveBeenCalledWith(identityData.id, undefined); + expect(mockFolderDAL.findById).toHaveBeenCalledWith(folderData.id, undefined); + expect(mockFolderCommitDAL.create).toHaveBeenCalledWith( + expect.objectContaining({ + actorType: ActorType.IDENTITY, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + actorMetadata: expect.objectContaining({ name: identityData.name }), + message: data.message, + folderId: data.folderId, + envId: folderData.envId + }), + undefined + ); + expect(mockFolderCommitChangesDAL.insertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + folderCommitId: commitData.id, + changeType: data.changes[0].type, + folderVersionId: data.changes[0].folderVersionId + }) + ]), + undefined + ); + expect(mockFolderCommitQueueService.scheduleTreeCheckpoint).toHaveBeenCalledWith(folderData.envId); + expect(result).toEqual(commitData); + }); + + it("should throw NotFoundError when folder does not exist", async () => { + // Arrange + mockFolderDAL.findById.mockResolvedValue(null); + + const data = { + actor: { + type: ActorType.PLATFORM + }, + message: "Test commit", + folderId: "non-existent-folder", + changes: [] + }; + + // Act & Assert + await expect(folderCommitService.createCommit(data)).rejects.toThrow(NotFoundError); + expect(mockFolderDAL.findById).toHaveBeenCalledWith("non-existent-folder", undefined); + }); + }); + + describe("addCommitChange", () => { + it("should successfully add a change to an existing commit", async () => { + // Arrange + const commitData = { id: "commit-id", folderId: "folder-id" }; + const changeData = { id: "change-id", folderCommitId: "commit-id" }; + + mockFolderCommitDAL.findById.mockResolvedValue(commitData); + mockFolderCommitChangesDAL.create.mockResolvedValue(changeData); + + const data = { + folderCommitId: commitData.id, + changeType: CommitType.ADD, + secretVersionId: "secret-version-1" + }; + + // Act + const result = await folderCommitService.addCommitChange(data); + + // Assert + expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith(commitData.id, undefined); + expect(mockFolderCommitChangesDAL.create).toHaveBeenCalledWith(data, undefined); + expect(result).toEqual(changeData); + }); + + it("should throw BadRequestError when neither secretVersionId nor folderVersionId is provided", async () => { + // Arrange + const data = { + folderCommitId: "commit-id", + changeType: CommitType.ADD + }; + + // Act & Assert + await expect(folderCommitService.addCommitChange(data)).rejects.toThrow(BadRequestError); + }); + + it("should throw NotFoundError when commit does not exist", async () => { + // Arrange + mockFolderCommitDAL.findById.mockResolvedValue(null); + + const data = { + folderCommitId: "non-existent-commit", + changeType: CommitType.ADD, + secretVersionId: "secret-version-1" + }; + + // Act & Assert + await expect(folderCommitService.addCommitChange(data)).rejects.toThrow(NotFoundError); + expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith("non-existent-commit", undefined); + }); + }); + + // Note: reconstructFolderState is an internal function not exposed in the public API + // We'll test it indirectly through compareFolderStates + + describe("compareFolderStates", () => { + it("should mark all resources as creates when currentCommitId is not provided", async () => { + // Arrange + const targetCommitId = "target-commit-id"; + const targetCommit = { id: targetCommitId, commitId: 1, folderId: "folder-id" }; + + mockFolderCommitDAL.findById.mockResolvedValue(targetCommit); + // Mock how compareFolderStates would process the results internally + mockFolderCheckpointDAL.findNearestCheckpoint.mockResolvedValue({ id: "checkpoint-id", commitId: "hash-0" }); + mockFolderCheckpointResourcesDAL.findByCheckpointId.mockResolvedValue([ + { secretVersionId: "secret-version-1", referencedSecretId: "secret-1" }, + { folderVersionId: "folder-version-1", referencedFolderId: "folder-1" } + ]); + mockFolderCommitDAL.findCommitsToRecreate.mockResolvedValue([]); + mockProjectDAL.findProjectByEnvId.mockResolvedValue({ + id: "project-id", + name: "test-project", + type: ProjectType.SecretManager + }); + + // Act + const result = await folderCommitService.compareFolderStates({ + targetCommitId + }); + + // Assert + expect(mockFolderCommitDAL.findById).toHaveBeenCalledWith(targetCommitId, undefined); + + // Verify we get resources marked as create + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + changeType: "create", + commitId: targetCommit.commitId + }) + ]) + ); + }); + }); + + describe("createFolderCheckpoint", () => { + it("should successfully create a checkpoint when force is true", async () => { + // Arrange + const folderCommitId = "commit-id"; + const folderId = "folder-id"; + const checkpointData = { id: "checkpoint-id", folderCommitId }; + + mockFolderDAL.findByParentId.mockResolvedValue([{ id: "subfolder-id" }]); + mockFolderVersionDAL.findLatestFolderVersions.mockResolvedValue({ "subfolder-id": { id: "folder-version-1" } }); + mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([{ id: "secret-version-1" }]); + mockFolderCheckpointDAL.create.mockResolvedValue(checkpointData); + + // Act + const result = await folderCommitService.createFolderCheckpoint({ + folderId, + folderCommitId, + force: true + }); + + // Assert + expect(mockFolderCheckpointDAL.create).toHaveBeenCalledWith({ folderCommitId }, undefined); + expect(mockFolderCheckpointResourcesDAL.insertMany).toHaveBeenCalled(); + expect(result).toBe(folderCommitId); + }); + }); + + describe("deepRollbackFolder", () => { + it("should throw NotFoundError when commit doesn't exist", async () => { + // Arrange + const targetCommitId = "non-existent-commit"; + const envId = "env-id"; + const actorId = "user-id"; + const actorType = ActorType.USER; + const projectId = "project-id"; + + // Mock the transaction to properly handle the error + mockFolderCommitDAL.transaction.mockImplementation(async (callback) => { + return await callback({} as Knex); + }); + + // Mock findById to return null inside the transaction + mockFolderCommitDAL.findById.mockResolvedValue(null); + + // Act & Assert + await expect( + folderCommitService.deepRollbackFolder(targetCommitId, envId, actorId, actorType, projectId) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("createFolderTreeCheckpoint", () => { + it("should create a tree checkpoint when checkpoint window is exceeded", async () => { + // Arrange + const envId = "env-id"; + const folderCommitId = "commit-id"; + const latestCommit = { id: folderCommitId }; + const latestTreeCheckpoint = { id: "tree-checkpoint-id", folderCommitId: "old-commit-id" }; + const folders = [ + { id: "folder-1", isReserved: false }, + { id: "folder-2", isReserved: false }, + { id: "folder-3", isReserved: true } // Reserved folders should be filtered out + ]; + const folderCommits = [ + { folderId: "folder-1", id: "commit-1" }, + { folderId: "folder-2", id: "commit-2" } + ]; + const treeCheckpoint = { id: "new-tree-checkpoint-id" }; + + mockFolderCommitDAL.findLatestEnvCommit.mockResolvedValue(latestCommit); + mockFolderTreeCheckpointDAL.findLatestByEnvId.mockResolvedValue(latestTreeCheckpoint); + mockFolderCommitDAL.getEnvNumberOfCommitsSince.mockResolvedValue(15); // More than PIT_TREE_CHECKPOINT_WINDOW (10) + mockFolderDAL.findByEnvId.mockResolvedValue(folders); + mockFolderCommitDAL.findMultipleLatestCommits.mockResolvedValue(folderCommits); + mockFolderTreeCheckpointDAL.create.mockResolvedValue(treeCheckpoint); + + // Act + await folderCommitService.createFolderTreeCheckpoint(envId); + + // Assert + expect(mockFolderCommitDAL.findLatestEnvCommit).toHaveBeenCalledWith(envId, undefined); + expect(mockFolderTreeCheckpointDAL.create).toHaveBeenCalledWith({ folderCommitId }, undefined); + }); + }); + + describe("applyFolderStateDifferences", () => { + it("should process changes correctly", async () => { + // Arrange + const folderId = "folder-id"; + const projectId = "project-id"; + const actorId = "user-id"; + const actorType = ActorType.USER; + + const differences = [ + { + id: "secret-1", + versionId: "v1", + changeType: ChangeType.CREATE, + commitId: BigInt(1) + } as ResourceChange, + { + id: "folder-1", + versionId: "v2", + changeType: ChangeType.UPDATE, + commitId: BigInt(1), + folderName: "Test Folder", + folderVersion: "v2" + } as ResourceChange + ]; + + const secretVersions = { + "secret-1": { + id: "secret-version-1", + createdAt: new Date(), + updatedAt: new Date(), + type: "shared", + folderId: "folder-1", + secretId: "secret-1", + version: 1, + key: "SECRET_KEY", + encryptedValue: Buffer.from("encrypted"), + encryptedComment: Buffer.from("comment"), + skipMultilineEncoding: false, + userId: "user-1", + envId: "env-1", + metadata: {} + } as TSecretVersionsV2 + }; + + const folderVersions = { + "folder-1": { + folderId: "folder-1", + version: 1, + name: "Test Folder", + envId: "env-1" + } as TSecretFolderVersions + }; + + // Mock folder lookup for the folder being processed + mockFolderDAL.findById.mockImplementation((id) => { + if (id === folderId) { + return Promise.resolve({ id: folderId, envId: "env-1" }); + } + return Promise.resolve(null); + }); + + // Mock latest commit lookup + mockFolderCommitDAL.findLatestCommit.mockImplementation((id) => { + if (id === folderId) { + return Promise.resolve({ id: "latest-commit-id", folderId }); + } + return Promise.resolve(null); + }); + + // Make sure findByParentId returns an array, not undefined + mockFolderDAL.findByParentId.mockResolvedValue([]); + + // Make sure other required functions return appropriate values + mockFolderCheckpointDAL.findLatestByFolderId.mockResolvedValue(null); + mockSecretVersionV2BridgeDAL.findLatestVersionByFolderId.mockResolvedValue([]); + + // These mocks need to return objects with an id field + mockSecretVersionV2BridgeDAL.findByIdsWithLatestVersion.mockResolvedValue(Object.values(secretVersions)); + mockFolderVersionDAL.findByIdsWithLatestVersion.mockResolvedValue(Object.values(folderVersions)); + mockSecretV2BridgeDAL.insertMany.mockResolvedValue([{ id: "new-secret-1" }]); + mockSecretVersionV2BridgeDAL.create.mockResolvedValue({ id: "new-secret-version-1" }); + mockFolderDAL.updateById.mockResolvedValue({ id: "updated-folder-1" }); + mockFolderVersionDAL.create.mockResolvedValue({ id: "new-folder-version-1" }); + mockFolderCommitDAL.create.mockResolvedValue({ id: "new-commit-id" }); + mockSecretVersionV2BridgeDAL.findLatestVersionMany.mockResolvedValue([ + { + id: "secret-version-1", + createdAt: new Date(), + updatedAt: new Date(), + type: "shared", + folderId: "folder-1", + secretId: "secret-1", + version: 1, + key: "SECRET_KEY", + encryptedValue: Buffer.from("encrypted"), + encryptedComment: Buffer.from("comment"), + skipMultilineEncoding: false, + userId: "user-1", + envId: "env-1", + metadata: {} + } + ]); + + // Mock transaction + mockFolderCommitDAL.transaction.mockImplementation((callback: TransactionCallback) => callback({} as Knex)); + + // Act + const result = await folderCommitService.applyFolderStateDifferences({ + differences, + actorInfo: { + actorType, + actorId, + message: "Applying changes" + }, + folderId, + projectId, + reconstructNewFolders: false + }); + + // Assert + expect(mockFolderCommitDAL.create).toHaveBeenCalled(); + expect(mockSecretV2BridgeDAL.invalidateSecretCacheByProjectId).toHaveBeenCalledWith(projectId); + + // Check that we got the right counts + expect(result.totalChanges).toEqual(2); + }); + }); +}); diff --git a/backend/src/services/folder-commit/folder-commit-service.ts b/backend/src/services/folder-commit/folder-commit-service.ts new file mode 100644 index 000000000..8c0bc8ebf --- /dev/null +++ b/backend/src/services/folder-commit/folder-commit-service.ts @@ -0,0 +1,2173 @@ +/* eslint-disable no-await-in-loop */ +import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; + +import { + ActionProjectType, + TSecretFolders, + TSecretFolderVersions, + TSecretV2TagJunctionInsert, + TSecretVersionsV2 +} from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { chunkArray } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { TFolderCheckpointDALFactory } from "../folder-checkpoint/folder-checkpoint-dal"; +import { TFolderCheckpointResourcesDALFactory } from "../folder-checkpoint-resources/folder-checkpoint-resources-dal"; +import { TFolderCommitChangesDALFactory } from "../folder-commit-changes/folder-commit-changes-dal"; +import { TFolderTreeCheckpointDALFactory } from "../folder-tree-checkpoint/folder-tree-checkpoint-dal"; +import { TFolderTreeCheckpointResourcesDALFactory } from "../folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; +import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; +import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TFolderCommitDALFactory } from "./folder-commit-dal"; +import { TFolderCommitQueueServiceFactory } from "./folder-commit-queue"; + +export enum ChangeType { + ADD = "add", + DELETE = "delete", + UPDATE = "update", + CREATE = "create" +} + +export enum CommitType { + ADD = "add", + DELETE = "delete" +} + +export enum ResourceType { + SECRET = "secret", + FOLDER = "folder" +} + +type TCreateCommitDTO = { + actor: { + type: string; + metadata?: { + name?: string; + id?: string; + }; + }; + message?: string; + folderId: string; + changes: { + type: string; + secretVersionId?: string; + folderVersionId?: string; + isUpdate?: boolean; + folderId?: string; + }[]; + omitIgnoreFilter?: boolean; +}; + +type TCommitChangeDTO = { + folderCommitId: string; + changeType: string; + secretVersionId?: string; + folderVersionId?: string; +}; + +type BaseChange = { + id: string; + versionId: string; + oldVersionId?: string; + changeType: ChangeType; + commitId: bigint; + createdAt?: Date; + parentId?: string; + isUpdate?: boolean; + fromVersion?: string; +}; + +type SecretChange = BaseChange & { + type: ResourceType.SECRET; + secretKey: string; + secretVersion: string; + secretId: string; + versions?: { + secretKey?: string; + secretComment?: string; + skipMultilineEncoding?: boolean | null; + metadata?: unknown; + tags?: string[] | null; + secretValue?: string; + }[]; +}; + +type FolderChange = BaseChange & { + type: ResourceType.FOLDER; + folderName: string; + folderVersion: string; + versions?: { + name: string; + description?: string | null; + }[]; +}; + +type SecretTargetChange = { + type: ResourceType.SECRET; + id: string; + versionId: string; + secretKey: string; + secretVersion: string; + fromVersion?: string; +}; + +type FolderTargetChange = { + type: ResourceType.FOLDER; + id: string; + versionId: string; + folderName: string; + folderVersion: string; + fromVersion?: string; +}; + +export type ResourceChange = SecretChange | FolderChange; + +type ActorInfo = { + actorType: string; + actorId?: string; + message?: string; +}; + +type StateChangeResult = { + secretChangesCount: number; + folderChangesCount: number; + totalChanges: number; +}; + +type TFolderCommitServiceFactoryDep = { + folderCommitDAL: TFolderCommitDALFactory; + folderCommitChangesDAL: TFolderCommitChangesDALFactory; + folderCheckpointDAL: TFolderCheckpointDALFactory; + folderCheckpointResourcesDAL: TFolderCheckpointResourcesDALFactory; + folderTreeCheckpointDAL: TFolderTreeCheckpointDALFactory; + folderTreeCheckpointResourcesDAL: TFolderTreeCheckpointResourcesDALFactory; + userDAL: TUserDALFactory; + identityDAL: TIdentityDALFactory; + folderDAL: TSecretFolderDALFactory; + folderVersionDAL: TSecretFolderVersionDALFactory; + secretVersionV2BridgeDAL: TSecretVersionV2DALFactory; + secretV2BridgeDAL: TSecretV2BridgeDALFactory; + projectDAL: Pick; + folderCommitQueueService?: Pick< + TFolderCommitQueueServiceFactory, + "scheduleTreeCheckpoint" | "createFolderTreeCheckpoint" + >; + permissionService?: TPermissionServiceFactory; + kmsService: Pick; + secretTagDAL: Pick< + TSecretTagDALFactory, + | "findSecretTagsByVersionId" + | "saveTagsToSecretV2" + | "findSecretTagsBySecretId" + | "deleteTagsToSecretV2" + | "saveTagsToSecretVersionV2" + >; + resourceMetadataDAL: Pick; +}; + +export const folderCommitServiceFactory = ({ + folderCommitDAL, + folderCommitChangesDAL, + folderCheckpointDAL, + folderTreeCheckpointDAL, + folderCheckpointResourcesDAL, + userDAL, + identityDAL, + folderDAL, + folderVersionDAL, + secretVersionV2BridgeDAL, + projectDAL, + secretV2BridgeDAL, + folderTreeCheckpointResourcesDAL, + folderCommitQueueService, + permissionService, + kmsService, + secretTagDAL, + resourceMetadataDAL +}: TFolderCommitServiceFactoryDep) => { + const appCfg = getConfig(); + + const checkProjectCommitReadPermission = async ({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }: { + actor: ActorType; + actorId: string; + projectId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + }) => { + if (!permissionService) { + throw new Error("Permission service not initialized"); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + }; + + /** + * Fetches all resources within a folder + */ + const getFolderResources = async (folderId: string, tx?: Knex) => { + const resources = []; + const subFolders = await folderDAL.findByParentId(folderId, tx); + + if (subFolders.length > 0) { + const subFolderIds = subFolders.map((folder) => folder.id); + const folderVersions = await folderVersionDAL.findLatestFolderVersions(subFolderIds, tx); + resources.push( + ...Object.values(folderVersions).map((folderVersion) => ({ + folderVersionId: folderVersion.id, + secretVersionId: undefined + })) + ); + } + + const secretVersions = await secretVersionV2BridgeDAL.findLatestVersionByFolderId(folderId, tx); + if (secretVersions.length > 0) { + resources.push( + ...secretVersions.map((secretVersion) => ({ secretVersionId: secretVersion.id, folderVersionId: undefined })) + ); + } + + return resources; + }; + + /** + * Creates a checkpoint for a folder if necessary + */ + const createFolderCheckpoint = async ({ + folderId, + folderCommitId, + force = false, + tx + }: { + folderId: string; + folderCommitId?: string; + force?: boolean; + tx?: Knex; + }) => { + let latestCommitId = folderCommitId; + const latestCheckpoint = await folderCheckpointDAL.findLatestByFolderId(folderId, tx); + + if (!latestCommitId) { + const latestCommit = await folderCommitDAL.findLatestCommit(folderId, undefined, tx); + if (!latestCommit) { + throw new BadRequestError({ message: "Latest commit ID not found" }); + } + latestCommitId = latestCommit.id; + } + + if (!force && latestCheckpoint) { + const commitsSinceLastCheckpoint = await folderCommitDAL.getNumberOfCommitsSince( + folderId, + latestCheckpoint.folderCommitId, + tx + ); + if (commitsSinceLastCheckpoint < Number(appCfg.PIT_CHECKPOINT_WINDOW)) { + return; + } + } + + const checkpointResources = await getFolderResources(folderId, tx); + + const newCheckpoint = await folderCheckpointDAL.create( + { + folderCommitId: latestCommitId + }, + tx + ); + const batchSize = 500; + const chunks = chunkArray(checkpointResources, batchSize); + + await Promise.all( + chunks.map(async (chunk) => { + await folderCheckpointResourcesDAL.insertMany( + chunk.map((resource) => ({ folderCheckpointId: newCheckpoint.id, ...resource })), + tx + ); + }) + ); + + return latestCommitId; + }; + + /** + * Reconstructs the state of a folder at a specific commit + */ + const reconstructFolderState = async ( + folderCommitId: string, + tx?: Knex + ): Promise<(SecretTargetChange | FolderTargetChange)[]> => { + const targetCommit = await folderCommitDAL.findById(folderCommitId, tx); + if (!targetCommit) { + throw new NotFoundError({ message: `Commit with ID ${folderCommitId} not found` }); + } + + const nearestCheckpoint = await folderCheckpointDAL.findNearestCheckpoint( + targetCommit.commitId, + targetCommit.folderId, + tx + ); + if (!nearestCheckpoint) { + throw new NotFoundError({ message: `Nearest checkpoint not found for commit ${folderCommitId}` }); + } + + const checkpointResources = await folderCheckpointResourcesDAL.findByCheckpointId(nearestCheckpoint.id, tx); + + const folderState: Record = {}; + + // Add all checkpoint resources to initial state + checkpointResources.forEach((resource) => { + if (resource.secretVersionId && resource.referencedSecretId) { + folderState[`secret-${resource.referencedSecretId}`] = { + type: ResourceType.SECRET, + id: resource.referencedSecretId, + versionId: resource.secretVersionId, + secretKey: resource.secretKey, + secretVersion: resource.secretVersion + } as SecretTargetChange; + } else if (resource.folderVersionId && resource.referencedFolderId) { + folderState[`folder-${resource.referencedFolderId}`] = { + type: ResourceType.FOLDER, + id: resource.referencedFolderId, + versionId: resource.folderVersionId, + folderName: resource.folderName, + folderVersion: resource.folderVersion + } as FolderTargetChange; + } + }); + + const commitsToRecreate = await folderCommitDAL.findCommitsToRecreate( + targetCommit.folderId, + targetCommit.commitId, + nearestCheckpoint.commitId, + tx + ); + + // Process commits to recreate final state + for (const commit of commitsToRecreate) { + // eslint-disable-next-line no-continue + if (!commit.changes) continue; + + for (const change of commit.changes) { + if (change.secretVersionId && change.referencedSecretId) { + const key = `secret-${change.referencedSecretId}`; + + if (change.changeType.toLowerCase() === "add") { + folderState[key] = { + type: ResourceType.SECRET, + id: change.referencedSecretId, + versionId: change.secretVersionId, + secretKey: change.secretKey, + secretVersion: change.secretVersion + } as SecretTargetChange; + } else if (change.changeType.toLowerCase() === "delete") { + delete folderState[key]; + } + } else if (change.folderVersionId && change.referencedFolderId) { + const key = `folder-${change.referencedFolderId}`; + + if (change.changeType.toLowerCase() === "add") { + folderState[key] = { + type: ResourceType.FOLDER, + id: change.referencedFolderId, + versionId: change.folderVersionId, + folderName: change.folderName, + folderVersion: change.folderVersion + } as FolderTargetChange; + } else if (change.changeType.toLowerCase() === "delete") { + delete folderState[key]; + } + } + } + } + return Object.values(folderState); + }; + + /** + * Compares folder states between two commits and returns the differences + */ + const compareFolderStates = async ({ + currentCommitId, + targetCommitId, + defaultOperation = "create", + tx + }: { + currentCommitId?: string; + targetCommitId: string; + defaultOperation?: "create" | "update" | "delete"; + tx?: Knex; + }): Promise => { + const targetCommit = await folderCommitDAL.findById(targetCommitId, tx); + if (!targetCommit) { + throw new NotFoundError({ message: `Commit with ID ${targetCommitId} not found` }); + } + + const project = await projectDAL.findProjectByEnvId(targetCommit.envId, tx); + + if (!project) { + throw new NotFoundError({ message: `No project found for envId ${targetCommit.envId}` }); + } + + // If currentCommitId is not provided, mark all resources in target as creates + if (!currentCommitId) { + const targetState = await reconstructFolderState(targetCommitId, tx); + + return targetState + .map((resource): ResourceChange | null => { + if (resource.type === ResourceType.SECRET) { + return { + type: ResourceType.SECRET, + id: resource.id, + versionId: resource.versionId, + changeType: defaultOperation as ChangeType, + commitId: targetCommit.commitId, + secretKey: resource.secretKey, + secretVersion: resource.secretVersion, + secretId: resource.id + }; + } + if (resource.type === ResourceType.FOLDER) { + return { + type: ResourceType.FOLDER, + id: resource.id, + versionId: resource.versionId, + changeType: defaultOperation as ChangeType, + commitId: targetCommit.commitId, + folderName: resource.folderName, + folderVersion: resource.folderVersion + }; + } + return null; + }) + .filter((change): change is ResourceChange => !!change); + } + + // Original logic for when currentCommitId is provided + const currentState = await reconstructFolderState(currentCommitId, tx); + const targetState = await reconstructFolderState(targetCommitId, tx); + + // Create lookup maps for easier comparison + const currentMap: Record = {}; + const targetMap: Record< + string, + { + type: string; + id: string; + versionId: string; + secretKey?: string; + secretVersion?: string; + folderName?: string; + folderVersion?: string; + fromVersion?: string; + } + > = {}; + + // Build lookup maps + currentState.forEach((resource) => { + const key = `${resource.type}-${resource.id}`; + currentMap[key] = resource; + }); + + targetState.forEach((resource) => { + const key = `${resource.type}-${resource.id}`; + targetMap[key] = resource; + }); + + // Track differences + const differences: ResourceChange[] = []; + + // Find deletes and updates + Object.keys(currentMap).forEach((key) => { + const currentResource = currentMap[key]; + const targetResource = targetMap[key]; + + if (!targetResource) { + // Resource was deleted + if (currentResource.type === ResourceType.SECRET) { + differences.push({ + type: ResourceType.SECRET, + id: currentResource.id, + versionId: currentResource.versionId, + changeType: ChangeType.DELETE, + commitId: targetCommit.commitId, + secretKey: currentResource.secretKey, + secretVersion: currentResource.secretVersion, + secretId: currentResource.id, + fromVersion: currentResource.versionId + }); + } else if (currentResource.type === ResourceType.FOLDER) { + differences.push({ + type: ResourceType.FOLDER, + id: currentResource.id, + versionId: currentResource.versionId, + changeType: ChangeType.DELETE, + commitId: targetCommit.commitId, + folderName: currentResource.folderName, + folderVersion: currentResource.folderVersion, + fromVersion: currentResource.versionId + }); + } + } else if (currentResource.versionId !== targetResource.versionId) { + // Resource was updated + if (targetResource.type === ResourceType.SECRET) { + const secretCurrentResource = currentResource as SecretTargetChange; + const secretTargetResource = targetResource as SecretTargetChange; + differences.push({ + type: ResourceType.SECRET, + id: secretTargetResource.id, + versionId: secretTargetResource.versionId, + changeType: ChangeType.UPDATE, + commitId: targetCommit.commitId, + secretKey: secretTargetResource.secretKey, + secretVersion: secretTargetResource.secretVersion, + secretId: secretTargetResource.id, + fromVersion: secretCurrentResource.secretVersion + }); + } else if (targetResource.type === ResourceType.FOLDER) { + const folderCurrentResource = currentResource as FolderTargetChange; + const folderTargetResource = targetResource as FolderTargetChange; + + differences.push({ + type: ResourceType.FOLDER, + id: folderTargetResource.id, + versionId: folderTargetResource.versionId, + changeType: ChangeType.UPDATE, + commitId: targetCommit.commitId, + folderName: folderTargetResource.folderName, + folderVersion: folderTargetResource.folderVersion, + fromVersion: folderCurrentResource.folderVersion + }); + } + } + }); + + // Find new resources + Object.keys(targetMap).forEach((key) => { + if (!currentMap[key]) { + const targetResource = targetMap[key]; + if (targetResource.type === ResourceType.SECRET) { + const secretTargetResource = targetResource as SecretTargetChange; + differences.push({ + type: ResourceType.SECRET, + id: secretTargetResource.id, + versionId: secretTargetResource.versionId, + changeType: ChangeType.CREATE, + commitId: targetCommit.commitId, + createdAt: targetCommit.createdAt, + secretKey: secretTargetResource.secretKey, + secretVersion: secretTargetResource.secretVersion, + secretId: secretTargetResource.id + }); + } else if (targetResource.type === ResourceType.FOLDER) { + const folderTargetResource = targetResource as FolderTargetChange; + differences.push({ + type: ResourceType.FOLDER, + id: folderTargetResource.id, + versionId: folderTargetResource.versionId, + changeType: ChangeType.CREATE, + commitId: targetCommit.commitId, + createdAt: targetCommit.createdAt, + folderName: folderTargetResource.folderName, + folderVersion: folderTargetResource.folderVersion + }); + } + } + }); + + const removeNoChangeUpdate: string[] = []; + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: project.id + }); + + await Promise.all( + differences.map(async (change) => { + if (change.changeType === ChangeType.UPDATE) { + if (change.type === ResourceType.FOLDER && change.folderVersion && change.fromVersion) { + const versions = await folderVersionDAL.find({ + folderId: change.id, + $in: { + version: [Number(change.folderVersion), Number(change.fromVersion)] + } + }); + const versionsShaped = versions.map((version) => ({ + name: version.name, + description: version.description + })); + const uniqueVersions = versionsShaped.filter( + (item, index, arr) => + arr.findIndex((other) => + Object.entries(item).every( + ([key, value]) => JSON.stringify(value) === JSON.stringify(other[key as keyof typeof other]) + ) + ) === index + ); + if (uniqueVersions.length === 1) { + removeNoChangeUpdate.push(change.id); + } + } else if (change.type === ResourceType.SECRET && change.secretVersion && change.fromVersion) { + const versions = await secretVersionV2BridgeDAL.findVersionsBySecretIdWithActors({ + secretId: change.id, + projectId: project.id, + secretVersions: [change.secretVersion, change.fromVersion] + }); + const versionsShaped = versions.map((el) => ({ + secretKey: el.key, + secretComment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "", + skipMultilineEncoding: el.skipMultilineEncoding, + secretReminderRepeatDays: el.reminderRepeatDays, + tags: el.tags, + metadata: el.metadata, + secretReminderNote: el.reminderNote, + secretValue: el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "" + })); + const uniqueVersions = versionsShaped.filter( + (item, index, arr) => + arr.findIndex((other) => + Object.entries(item).every( + ([key, value]) => JSON.stringify(value) === JSON.stringify(other[key as keyof typeof other]) + ) + ) === index + ); + if (uniqueVersions.length === 1) { + removeNoChangeUpdate.push(change.id); + } + } + } + }) + ); + return differences.filter((change) => !removeNoChangeUpdate.includes(change.id)); + }; + + /** + * Adds a change to an existing commit + */ + const addCommitChange = async (data: TCommitChangeDTO, tx?: Knex) => { + try { + if (!data.secretVersionId && !data.folderVersionId) { + throw new BadRequestError({ message: "Either secretVersionId or folderVersionId must be provided" }); + } + + const commit = await folderCommitDAL.findById(data.folderCommitId, tx); + if (!commit) { + throw new NotFoundError({ message: `Commit with ID ${data.folderCommitId} not found` }); + } + + return await folderCommitChangesDAL.create(data, tx); + } catch (error) { + if (error instanceof NotFoundError || error instanceof BadRequestError) { + throw error; + } + throw new DatabaseError({ error, name: "AddCommitChange" }); + } + }; + + const createDeleteCommitForNestedFolders = async ({ + folderId, + actorMetadata, + actorType, + envId, + parentFolderName, + step = 1, + tx + }: { + folderId: string; + actorMetadata: Record; + actorType: string; + envId: string; + parentFolderName: string; + step?: number; + tx?: Knex; + }) => { + if (step > 20) { + logger.info(`createDeleteCommitForNestedFolders - Max step reached for folder ${folderId}`); + return; + } + logger.info(`Creating delete commit for nested folders ${folderId}`); + const folderVersion = await folderVersionDAL.findLatestVersion(folderId, tx); + if (!folderVersion) { + logger.info(`No folder version found for ${folderId}`); + return; + } + const lastFolderCommit = await folderCommitDAL.findLatestCommit(folderId, undefined, tx); + if (!lastFolderCommit) { + logger.info(`No commit found for folder ${folderId}`); + return; + } + const folderState = await reconstructFolderState(lastFolderCommit.id, tx); + const changes = folderState.map((resource) => ({ + type: ChangeType.DELETE, + folderId: resource.id, + folderName: resource.type === ResourceType.FOLDER ? resource.folderName : undefined, + secretVersionId: resource.type === ResourceType.SECRET ? resource.versionId : undefined, + folderVersionId: resource.type === ResourceType.FOLDER ? resource.versionId : undefined, + secretKey: resource.type === ResourceType.SECRET ? resource.secretKey : undefined + })); + logger.info(`Found ${changes.length} changes for ${folderId}`); + + const newCommit = await folderCommitDAL.create( + { + actorMetadata, + actorType, + message: `Parent folder ${parentFolderName} deleted`, + folderId, + envId + }, + tx + ); + + const batchSize = 500; + const chunks = chunkArray(changes, batchSize); + + await Promise.all( + chunks.map(async (chunk) => { + await folderCommitChangesDAL.insertMany( + chunk.map((change) => ({ + folderCommitId: newCommit.id, + changeType: CommitType.DELETE, + secretVersionId: change.secretVersionId, + folderVersionId: change.folderVersionId, + isUpdate: false + })), + tx + ); + }) + ); + + await Promise.all( + changes + .filter((change) => change.type === ChangeType.DELETE && change.folderVersionId) + .map(async (change) => { + await createDeleteCommitForNestedFolders({ + folderId: change.folderId, + actorMetadata, + actorType, + envId, + parentFolderName: folderVersion.name, + step: step + 1, + tx + }); + }) + ); + }; + + const compareSecretVersions = async ( + version1: TSecretVersionsV2 & { tags: { id: string }[] }, + version2: TSecretVersionsV2 & { tags: { id: string }[] }, + projectId: string + ) => { + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + const objectsEqual = (o1: unknown, o2: unknown): boolean => { + if (typeof o1 !== "object" || o1 === null || typeof o2 !== "object" || o2 === null) { + return o1 === o2; + } + + const obj1 = o1 as Record; + const obj2 = o2 as Record; + return ( + Object.keys(obj1).length === Object.keys(obj2).length && Object.keys(obj1).every((p) => obj1[p] === obj2[p]) + ); + }; + + const arraysEqual = (a1: unknown[], a2: unknown[]) => + a1.length === a2.length && a1.every((obj1) => a2.some((obj2) => objectsEqual(obj1, obj2))); + + const version1Reshaped = { + ...version1, + encryptedValue: version1.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: version1.encryptedValue }).toString() + : "", + encryptedComment: version1.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: version1.encryptedComment }).toString() + : "", + metadata: Array.isArray(version1.metadata) ? (version1.metadata as { key: string; value: string }[]) : [], + tags: version1.tags.map((tag) => tag.id) + }; + const version2Reshaped = { + ...version2, + encryptedValue: version2.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: version2.encryptedValue }).toString() + : "", + encryptedComment: version2.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: version2.encryptedComment }).toString() + : "", + metadata: Array.isArray(version2.metadata) ? (version2.metadata as { key: string; value: string }[]) : [], + tags: version2.tags.map((tag) => tag.id) + }; + return ( + version1Reshaped.key === version2Reshaped.key && + version1Reshaped.encryptedValue === version2Reshaped.encryptedValue && + version1Reshaped.encryptedComment === version2Reshaped.encryptedComment && + version1Reshaped.skipMultilineEncoding === version2Reshaped.skipMultilineEncoding && + arraysEqual(version1Reshaped.metadata, version2Reshaped.metadata) && + version1Reshaped.tags.length === version2Reshaped.tags.length && + version1Reshaped.tags.every((tag) => version2Reshaped.tags.includes(tag)) + ); + }; + + const filterIgnoredChanges = async ( + changes: { + type: string; + secretVersionId?: string; + folderVersionId?: string; + isUpdate?: boolean; + folderId?: string; + }[], + projectId: string, + tx?: Knex + ) => { + let filteredChanges = [...changes]; + for (const change of changes) { + if (change.type === ChangeType.ADD && change.isUpdate && change.secretVersionId) { + const secretVersions = await secretVersionV2BridgeDAL.findByIdAndPreviousVersion(change.secretVersionId, tx); + const comparison = await compareSecretVersions(secretVersions[0], secretVersions[1], projectId); + if (comparison) { + filteredChanges = filteredChanges.filter( + (filteredChange) => filteredChange.secretVersionId !== change.secretVersionId + ); + } + } + } + return filteredChanges; + }; + + /** + * Creates a new commit with the provided changes + */ + const createCommit = async (data: TCreateCommitDTO, tx?: Knex) => { + try { + const metadata = { ...data.actor.metadata } || {}; + + if (data.actor.type === ActorType.USER && data.actor.metadata?.id) { + const user = await userDAL.findById(data.actor.metadata?.id, tx); + metadata.name = user?.username; + } + + if (data.actor.type === ActorType.IDENTITY && data.actor.metadata?.id) { + const identity = await identityDAL.findById(data.actor.metadata?.id, tx); + metadata.name = identity?.name; + } + + const folder = await folderDAL.findById(data.folderId, tx); + if (!folder) { + throw new NotFoundError({ message: `Folder with ID ${data.folderId} not found` }); + } + + let { changes } = data; + if (!data.omitIgnoreFilter) { + const project = await projectDAL.findProjectByEnvId(folder.envId, tx); + + if (!project) { + return; + } + + changes = await filterIgnoredChanges(data.changes, project.id, tx); + if (changes.length === 0) { + return; + } + } + + const newCommit = await folderCommitDAL.create( + { + actorMetadata: metadata, + actorType: data.actor.type, + message: data.message, + folderId: data.folderId, + envId: folder.envId + }, + tx + ); + + const batchSize = 500; + const chunks = chunkArray(changes, batchSize); + + await Promise.all( + chunks.map(async (chunk) => { + await folderCommitChangesDAL.insertMany( + chunk.map((change) => ({ + folderCommitId: newCommit.id, + changeType: change.type, + secretVersionId: change.secretVersionId, + folderVersionId: change.folderVersionId, + isUpdate: change.isUpdate || false + })), + tx + ); + }) + ); + + await Promise.all( + changes.map(async (change) => { + if (change.type === ChangeType.DELETE && change.folderId) { + await createDeleteCommitForNestedFolders({ + folderId: change.folderId, + actorMetadata: metadata, + actorType: data.actor.type, + envId: folder.envId, + parentFolderName: folder.name, + tx + }); + } + }) + ); + + await createFolderCheckpoint({ folderId: data.folderId, folderCommitId: newCommit.id, tx }); + if (folderCommitQueueService) { + if (!folder.parentId) { + const previousTreeCommit = await folderTreeCheckpointDAL.findLatestByEnvId(folder.envId); + if (!previousTreeCommit) { + await folderCommitQueueService.createFolderTreeCheckpoint(folder.envId, newCommit.id, tx); + } + } + await folderCommitQueueService.scheduleTreeCheckpoint(folder.envId); + } + return newCommit; + } catch (error) { + if (error instanceof NotFoundError || error instanceof BadRequestError) { + throw error; + } + throw new DatabaseError({ error, name: "CreateCommit" }); + } + }; + + /** + * Process secret changes when applying folder state differences + */ + const processSecretChanges = async ( + changes: ResourceChange[], + secretVersions: Record, + actorInfo: ActorInfo, + folderId: string, + tx?: Knex + ) => { + const commitChanges = []; + const folder = await folderDAL.findById(folderId, tx); + if (!folder) { + return []; + } + const project = await projectDAL.findById(folder.projectId, tx); + + // Filter only secret changes using discriminated union + const secretChanges = changes.filter( + (change): change is ResourceChange & SecretChange => change.type === ResourceType.SECRET + ); + + // Collect all secretIds for batch lookup + const secretIds = secretChanges.map((change) => secretVersions[change.id]?.secretId).filter(Boolean); + + // Fetch all latest versions in one call + const latestVersionsMap = await secretVersionV2BridgeDAL.findLatestVersionMany(folderId, secretIds, tx); + + for (const change of secretChanges) { + const secretVersion = secretVersions[change.id]; + // eslint-disable-next-line no-continue + if (!secretVersion) continue; + + // Get the latest version from our batch result + const latestVersion = latestVersionsMap[secretVersion.secretId]; + const nextVersion = latestVersion ? latestVersion.version + 1 : 1; + + switch (change.changeType) { + case "create": + { + const newSecret = [ + { + id: change.id, + skipMultilineEncoding: secretVersion.skipMultilineEncoding, + version: nextVersion, + type: secretVersion.type, + key: secretVersion.key, + reminderNote: secretVersion.reminderNote, + reminderRepeatDays: secretVersion.reminderRepeatDays, + encryptedValue: secretVersion.encryptedValue, + encryptedComment: secretVersion.encryptedComment, + userId: secretVersion.userId, + folderId + } + ]; + await secretV2BridgeDAL.insertMany(newSecret, tx); + + const metadata: { key: string; value: string }[] = + (secretVersion.metadata as { key: string; value: string }[]) || []; + if (metadata.length > 0) { + await resourceMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + key, + value, + secretId: change.id, + orgId: project.orgId + })), + tx + ); + } + + const newVersion = await secretVersionV2BridgeDAL.create( + { + folderId, + secretId: secretVersion.secretId, + version: nextVersion, + encryptedValue: secretVersion.encryptedValue, + key: secretVersion.key, + encryptedComment: secretVersion.encryptedComment, + skipMultilineEncoding: secretVersion.skipMultilineEncoding, + reminderNote: secretVersion.reminderNote, + reminderRepeatDays: secretVersion.reminderRepeatDays, + userId: secretVersion.userId, + actorType: actorInfo.actorType, + envId: secretVersion.envId, + metadata: JSON.stringify(metadata), + ...(actorInfo.actorType === ActorType.IDENTITY && { identityActorId: actorInfo.actorId }), + ...(actorInfo.actorType === ActorType.USER && { userActorId: actorInfo.actorId }) + }, + tx + ); + + const secretTagsToBeInsert: TSecretV2TagJunctionInsert[] = []; + const secretTags = await secretTagDAL.findSecretTagsByVersionId(secretVersion.id, tx); + secretTags.forEach((tag) => { + secretTagsToBeInsert.push({ secrets_v2Id: change.id, secret_tagsId: tag.secret_tagsId }); + }); + await secretTagDAL.saveTagsToSecretV2(secretTagsToBeInsert, tx); + await secretTagDAL.saveTagsToSecretVersionV2( + secretTagsToBeInsert.map((tag) => ({ + secret_tagsId: tag.secret_tagsId, + secret_versions_v2Id: newVersion.id + })), + tx + ); + + commitChanges.push({ + type: ChangeType.ADD, + secretVersionId: newVersion.id + }); + } + break; + + case "update": + { + await secretV2BridgeDAL.updateById( + change.id, + { + skipMultilineEncoding: secretVersion?.skipMultilineEncoding, + version: nextVersion, + type: secretVersion?.type, + key: secretVersion?.key, + reminderNote: secretVersion?.reminderNote, + reminderRepeatDays: secretVersion?.reminderRepeatDays, + encryptedValue: secretVersion?.encryptedValue, + encryptedComment: secretVersion?.encryptedComment, + userId: secretVersion?.userId + }, + tx + ); + + const metadata: { key: string; value: string }[] = + (secretVersion.metadata as { key: string; value: string }[]) || []; + await resourceMetadataDAL.delete({ secretId: change.id }, tx); + if (metadata.length > 0) { + await resourceMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + key, + value, + secretId: change.id, + orgId: project.orgId + })), + tx + ); + } + + const newVersion = await secretVersionV2BridgeDAL.create( + { + version: nextVersion, + encryptedValue: secretVersion.encryptedValue, + key: secretVersion.key, + encryptedComment: secretVersion.encryptedComment, + skipMultilineEncoding: secretVersion.skipMultilineEncoding, + reminderNote: secretVersion.reminderNote, + reminderRepeatDays: secretVersion.reminderRepeatDays, + userId: secretVersion.userId, + metadata: JSON.stringify(metadata), + actorType: actorInfo.actorType, + envId: secretVersion.envId, + folderId, + secretId: secretVersion.secretId, + ...(actorInfo.actorType === ActorType.IDENTITY && { identityActorId: actorInfo.actorId }), + ...(actorInfo.actorType === ActorType.USER && { userActorId: actorInfo.actorId }) + }, + tx + ); + + let secretTagsToBeInsert: TSecretV2TagJunctionInsert[] = []; + const secretTagsToBeDelete: string[] = []; + const secretTags = await secretTagDAL.findSecretTagsByVersionId(secretVersion.id, tx); + secretTags.forEach((tag) => { + secretTagsToBeInsert.push({ secrets_v2Id: change.id, secret_tagsId: tag.secret_tagsId }); + }); + const currentTags = await secretTagDAL.findSecretTagsBySecretId(change.id, tx); + currentTags.forEach((tag) => { + if (!secretTagsToBeInsert.find((t) => t.secret_tagsId === tag.secret_tagsId)) { + secretTagsToBeDelete.push(tag.secret_tagsId); + secretTagsToBeInsert = secretTagsToBeInsert.filter((t) => t.secret_tagsId !== tag.secret_tagsId); + } + }); + await secretTagDAL.saveTagsToSecretV2(secretTagsToBeInsert, tx); + await secretTagDAL.saveTagsToSecretVersionV2( + secretTagsToBeInsert.map((tag) => ({ + secret_tagsId: tag.secret_tagsId, + secret_versions_v2Id: newVersion.id + })), + tx + ); + await secretTagDAL.deleteTagsToSecretV2( + { $in: { secret_tagsId: secretTagsToBeDelete }, secrets_v2Id: change.id }, + tx + ); + + commitChanges.push({ + type: ChangeType.ADD, + isUpdate: true, + secretVersionId: newVersion.id + }); + } + break; + + // Delete case remains unchanged + case "delete": + await secretV2BridgeDAL.deleteById(change.id, tx); + commitChanges.push({ + type: ChangeType.DELETE, + secretVersionId: change.versionId + }); + break; + + default: + throw new BadRequestError({ message: `Unknown change type: ${change.changeType}` }); + } + } + + return commitChanges; + }; + + /** + * Core function to apply folder state differences + */ + const applyFolderStateDifferencesFn = async ({ + differences, + actorInfo, + folderId, + projectId, + reconstructNewFolders, + reconstructUpToCommit, + step = 0, + tx + }: { + differences: ResourceChange[]; + actorInfo: ActorInfo; + folderId: string; + projectId: string; + reconstructNewFolders: boolean; + reconstructUpToCommit?: string; + step: number; + tx?: Knex; + }): Promise => { + /** + * Process folder changes when applying folder state differences + */ + const processFolderChanges = async ( + changes: ResourceChange[], + folderVersions: Record + ) => { + const commitChanges = []; + + // Filter only folder changes using discriminated union + const folderChanges = changes.filter( + (change): change is ResourceChange & FolderChange => change.type === ResourceType.FOLDER + ); + + for (const change of folderChanges) { + const folderVersion = folderVersions[change.id]; + + switch (change.changeType) { + case "create": + if (folderVersion) { + const newFolder = { + id: change.id, + parentId: folderId, + envId: folderVersion.envId, + version: (folderVersion.version || 1) + 1, + name: folderVersion.name, + description: folderVersion.description + }; + await folderDAL.create(newFolder, tx); + + const newFolderVersion = await folderVersionDAL.create( + { + folderId: change.id, + version: (folderVersion.version || 1) + 1, + name: folderVersion.name, + description: folderVersion.description, + envId: folderVersion.envId + }, + tx + ); + + if (reconstructNewFolders && reconstructUpToCommit && step < 20) { + const subFolderLatestCommit = await folderCommitDAL.findLatestCommitBetween({ + folderId: change.id, + endCommitId: reconstructUpToCommit, + tx + }); + if (subFolderLatestCommit) { + const subFolderDiff = await compareFolderStates({ + targetCommitId: subFolderLatestCommit.id, + tx + }); + if (subFolderDiff?.length > 0) { + await applyFolderStateDifferencesFn({ + differences: subFolderDiff, + actorInfo, + folderId: change.id, + projectId, + reconstructNewFolders, + reconstructUpToCommit, + step: step + 1, + tx + }); + } + } + } + + commitChanges.push({ + type: ChangeType.ADD, + folderVersionId: newFolderVersion.id + }); + } + break; + + case "update": + if (change.versionId) { + const latestVersionDetails = await folderVersionDAL.findByIdsWithLatestVersion( + [change.id], + [change.versionId], + tx + ); + if (latestVersionDetails && Object.keys(latestVersionDetails).length > 0) { + const versionDetails = Object.values(latestVersionDetails)[0]; + await folderDAL.updateById( + change.id, + { + parentId: folderId, + envId: versionDetails.envId, + version: (versionDetails.version || 1) + 1, + name: versionDetails.name, + description: versionDetails.description + }, + tx + ); + + const newFolderVersion = await folderVersionDAL.create( + { + folderId: change.id, + version: (versionDetails.version || 1) + 1, + name: versionDetails.name, + description: versionDetails.description, + envId: versionDetails.envId + }, + tx + ); + + commitChanges.push({ + type: ChangeType.ADD, + isUpdate: true, + folderVersionId: newFolderVersion.id + }); + } + } + break; + + case "delete": + await folderDAL.deleteById(change.id, tx); + + commitChanges.push({ + type: ChangeType.DELETE, + folderVersionId: change.versionId, + folderId: change.id + }); + break; + + default: + throw new BadRequestError({ message: `Unknown change type: ${change.changeType}` }); + } + } + return commitChanges; + }; + + // Group differences by type for more efficient processing using discriminated unions + const secretChanges = differences.filter( + (diff): diff is ResourceChange & SecretChange => diff.type === ResourceType.SECRET + ); + const folderChanges = differences.filter( + (diff): diff is ResourceChange & FolderChange => diff.type === ResourceType.FOLDER + ); + + // Batch fetch necessary data + const secretVersions = await secretVersionV2BridgeDAL.findByIdsWithLatestVersion( + folderId, + secretChanges.map((diff) => diff.id), + secretChanges.map((diff) => diff.versionId), + tx + ); + + const folderVersions = await folderVersionDAL.findByIdsWithLatestVersion( + folderChanges.map((diff) => diff.id), + folderChanges.map((diff) => diff.versionId), + tx + ); + + // Process changes in parallel + const [secretCommitChanges, folderCommitChanges] = await Promise.all([ + processSecretChanges(differences, secretVersions, actorInfo, folderId, tx), + processFolderChanges(differences, folderVersions) + ]); + + // Combine all changes + const allCommitChanges = [...secretCommitChanges, ...folderCommitChanges]; + + // Create a commit with all the changes + await createCommit( + { + actor: { + type: actorInfo.actorType, + metadata: { id: actorInfo.actorId } + }, + message: actorInfo.message || "Rolled back folder state", + folderId, + changes: allCommitChanges, + omitIgnoreFilter: true + }, + tx + ); + + // Invalidate cache to reflect the changes + await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); + + return { + secretChangesCount: secretChanges.length, + folderChangesCount: folderChanges.length, + totalChanges: differences.length + }; + }; + + /** + * Apply folder state differences with transaction handling + */ + const applyFolderStateDifferences = async (params: { + differences: ResourceChange[]; + actorInfo: ActorInfo; + folderId: string; + projectId: string; + reconstructNewFolders: boolean; + reconstructUpToCommit?: string; + tx?: Knex; + }): Promise => { + // If a transaction was provided, use it directly + if (params.tx) { + return applyFolderStateDifferencesFn({ ...params, step: 0 }); + } + + // Otherwise, start a new transaction + return folderCommitDAL.transaction((newTx) => applyFolderStateDifferencesFn({ ...params, tx: newTx, step: 0 })); + }; + + /** + * Retrieve a commit by ID + */ + const getCommitById = async ({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + tx + }: { + commitId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + tx?: Knex; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + return folderCommitDAL.findById(commitId, tx, projectId); + }; + + /** + * Get all commits for a folder + */ + const getCommitsByFolderId = async (folderId: string, tx?: Knex) => { + return folderCommitDAL.findByFolderId(folderId, tx); + }; + + const getCommitsForFolder = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + environment, + path, + offset = 0, + limit = 20, + search, + sort = "desc" + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + environment: string; + path: string; + offset: number; + limit: number; + search?: string; + sort: "asc" | "desc"; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) { + throw new NotFoundError({ + message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}` + }); + } + const folderCommits = await folderCommitDAL.findByFolderIdPaginated(folder.id, { + offset, + limit, + search, + sort + }); + return folderCommits; + }; + + const getCommitsCount = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + environment, + path + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + environment: string; + path: string; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) { + throw new NotFoundError({ + message: `Folder not found for project ID ${projectId}, environment ${environment}, path ${path}` + }); + } + const folderCommits = await folderCommitDAL.findByFolderId(folder.id); + return { count: folderCommits.length, folderId: folder.id }; + }; + + /** + * Get changes for a commit + */ + const getCommitChanges = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + commitId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + commitId: string; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + const changes = await folderCommitChangesDAL.findByCommitId(commitId, projectId); + const commit = await folderCommitDAL.findById(commitId, undefined, projectId); + const latestCommit = await folderCommitDAL.findLatestCommit(commit.folderId, projectId); + return { ...commit, changes, isLatest: commit.id === latestCommit?.id }; + }; + + /** + * Get checkpoints for a folder + */ + const getCheckpointsByFolderId = async (folderId: string, limit?: number, tx?: Knex) => { + return folderCheckpointDAL.findByFolderId(folderId, limit, tx); + }; + + /** + * Get the latest checkpoint for a folder + */ + const getLatestCheckpoint = async (folderId: string, tx?: Knex) => { + return folderCheckpointDAL.findLatestByFolderId(folderId, tx); + }; + + /** + * Initialize a folder with its current state + */ + const getFolderInitialChanges = async (folderId: string, envId: string, tx?: Knex) => { + const folderResources = await getFolderResources(folderId, tx); + const changes = folderResources.map((resource) => ({ type: ChangeType.ADD, ...resource })); + + if (changes.length > 0) { + return { + commit: { + actorMetadata: {}, + actorType: ActorType.PLATFORM, + message: "Initialized folder", + folderId, + envId + }, + changes: changes.map((change) => ({ + folderId, + changeType: change.type, + secretVersionId: change.secretVersionId, + folderVersionId: change.folderVersionId, + isUpdate: false + })) + }; + } + return {}; + }; + + /** + * Sort folders by hierarchy (parents before children) + */ + const sortFoldersByHierarchy = (folders: TSecretFolders[]) => { + // Create a map for quick lookup of children by parent ID + const childrenMap = new Map(); + + // Set of all folder IDs + const allFolderIds = new Set(); + + // Build the set of all folder IDs + folders.forEach((folder) => { + if (folder.id) { + allFolderIds.add(folder.id); + } + }); + + // Group folders by their parentId + folders.forEach((folder) => { + if (folder.parentId) { + const children = childrenMap.get(folder.parentId) || []; + children.push(folder); + childrenMap.set(folder.parentId, children); + } + }); + + // Find root folders - those with no parentId or with a parentId that doesn't exist + const rootFolders = folders.filter((folder) => !folder.parentId || !allFolderIds.has(folder.parentId)); + + // Process each level of the hierarchy + const result = []; + let currentLevel = rootFolders; + + while (currentLevel.length > 0) { + result.push(...currentLevel); + + const nextLevel = []; + for (const folder of currentLevel) { + if (folder.id) { + const children = childrenMap.get(folder.id) || []; + nextLevel.push(...children); + } + } + + currentLevel = nextLevel; + } + + return result; + }; + + /** + * Create a checkpoint for a folder tree + */ + const createFolderTreeCheckpoint = async (envId: string, folderCommitId?: string, tx?: Knex) => { + let latestCommitId = folderCommitId; + const latestTreeCheckpoint = await folderTreeCheckpointDAL.findLatestByEnvId(envId, tx); + + if (!latestCommitId) { + const latestCommit = await folderCommitDAL.findLatestEnvCommit(envId, tx); + if (!latestCommit) { + logger.info(`createFolderTreeCheckpoint - Latest commit ID not found for envId ${envId}`); + return; + } + latestCommitId = latestCommit.id; + } + + if (latestTreeCheckpoint) { + const commitsSinceLastCheckpoint = await folderCommitDAL.getEnvNumberOfCommitsSince( + envId, + latestTreeCheckpoint.folderCommitId, + tx + ); + if (commitsSinceLastCheckpoint < Number(appCfg.PIT_TREE_CHECKPOINT_WINDOW)) { + logger.info( + `createFolderTreeCheckpoint - Commits since last checkpoint ${commitsSinceLastCheckpoint} is less than ${appCfg.PIT_TREE_CHECKPOINT_WINDOW}` + ); + return; + } + } + + const folders = await folderDAL.findByEnvId(envId, tx); + const sortedFolders = sortFoldersByHierarchy(folders); + const filteredFoldersIds = sortedFolders.filter((folder) => !folder.isReserved).map((folder) => folder.id); + const folderCommits = await folderCommitDAL.findMultipleLatestCommits(filteredFoldersIds, tx); + const folderTreeCheckpoint = await folderTreeCheckpointDAL.create( + { + folderCommitId: latestCommitId + }, + tx + ); + await folderTreeCheckpointResourcesDAL.insertMany( + folderCommits.map((folderCommit) => ({ + folderTreeCheckpointId: folderTreeCheckpoint.id, + folderId: folderCommit.folderId, + folderCommitId: folderCommit.id + })), + tx + ); + }; + + const addNestedFolderChanges = async ({ + changes, + beforeCommit, + folderId, + folderName, + folderPath, + step = 1, + tx + }: { + changes: { + folderId: string; + folderName: string; + changes: ResourceChange[]; + folderPath?: string; + }[]; + beforeCommit: bigint; + folderId: string; + folderName?: string; + folderPath?: string; + step?: number; + tx?: Knex; + }) => { + if (step > 20) { + return; + } + const latestFolderCommit = await folderCommitDAL.findCommitBefore(folderId, beforeCommit, tx); + if (!latestFolderCommit) { + return; + } + const diff = await compareFolderStates({ + targetCommitId: latestFolderCommit.id, + tx + }); + changes.push({ + folderId, + folderName: folderName || "", + changes: diff, + folderPath: folderPath || "" + }); + await Promise.all( + diff.map(async (change) => { + if (change.type === ResourceType.FOLDER && change.changeType === ChangeType.CREATE) { + await addNestedFolderChanges({ + changes, + beforeCommit, + folderId: change.id, + folderName: change.folderName, + folderPath: `${folderPath}/${change.folderName}`, + step: step + 1, + tx + }); + } + }) + ); + }; + + const deepCompareFolder = async ({ + targetCommitId, + envId, + projectId, + tx + }: { + targetCommitId: string; + envId: string; + projectId: string; + tx?: Knex; + }) => { + const targetCommit = await folderCommitDAL.findById(targetCommitId, tx); + if (!targetCommit) { + throw new NotFoundError({ message: `No commit found for commit ID ${targetCommitId}` }); + } + + const checkpoint = await folderTreeCheckpointDAL.findNearestCheckpoint(targetCommit.commitId, envId, tx); + if (!checkpoint) { + throw new NotFoundError({ message: `No checkpoint found for commit ID ${targetCommitId}` }); + } + + const folderCheckpointCommits = await folderTreeCheckpointResourcesDAL.findByTreeCheckpointId(checkpoint.id, tx); + const folderCommits = await folderCommitDAL.findAllCommitsBetween({ + envId, + startCommitId: checkpoint.commitId.toString(), + tx + }); + + // Group commits by folderId and keep only the latest + const folderGroups = new Map(); + + if (folderCheckpointCommits && folderCheckpointCommits.length > 0) { + for (const commit of folderCheckpointCommits) { + if (commit.commitId > targetCommit.commitId) { + folderGroups.set(commit.folderId, { + commitId: commit.commitId, + id: commit.folderCommitId + }); + } + } + } + + if (folderCommits && folderCommits.length > 0) { + for (const commit of folderCommits) { + const { folderId, commitId, id } = commit; + const existingCommit = folderGroups.get(folderId); + + if ((!existingCommit || commitId > existingCommit.commitId) && commitId > targetCommit.commitId) { + folderGroups.set(folderId, { commitId, id }); + } + } + } + + const folderDiffs = new Map(); + + // Process each folder to determine differences + await Promise.all( + Array.from(folderGroups.entries()).map(async ([folderId, commit]) => { + const previousCommit = await folderCommitDAL.findPreviousCommitTo( + folderId, + targetCommit.commitId.toString(), + tx + ); + let diff = []; + if (previousCommit && previousCommit.id !== commit.id) { + diff = await compareFolderStates({ + currentCommitId: commit.id, + targetCommitId: previousCommit.id, + tx + }); + } else { + diff = await compareFolderStates({ + targetCommitId: commit.id, + defaultOperation: "delete", + tx + }); + } + if (diff?.length > 0) { + folderDiffs.set(folderId, diff); + } + }) + ); + + // Apply changes in hierarchical order + const folderIds = Array.from(folderDiffs.keys()); + const folders = await folderDAL.findFoldersByRootAndIds({ rootId: targetCommit.folderId, folderIds }, tx); + const sortedFolders = sortFoldersByHierarchy(folders); + + const response: { + folderId: string; + folderName: string; + changes: ResourceChange[]; + folderPath?: string; + }[] = []; + for (const folder of sortedFolders) { + const diff = folderDiffs.get(folder.id); + if (diff) { + const folderPath = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]); + response.push({ + folderId: folder.id, + folderName: folder.name, + changes: diff, + folderPath: folderPath?.[0]?.path + }); + const recreatedFolders = diff + .filter( + (change): change is FolderChange => + change.type === ResourceType.FOLDER && change.changeType === ChangeType.CREATE + ) + .map((change) => ({ + id: change.id, + folderName: change.folderName, + folderPath: folderPath?.[0]?.path + })); + await Promise.all( + recreatedFolders.map(async (change) => { + const nestedFolderPath = folderPath?.[0]?.path; + await addNestedFolderChanges({ + changes: response, + beforeCommit: targetCommit.commitId, + folderId: change.id, + folderName: change.folderName, + folderPath: `${nestedFolderPath !== "/" ? nestedFolderPath : ""}/${change.folderName}`, + tx + }); + }) + ); + } + } + return response; + }; + + /** + * Roll back a folder tree to a specific commit + */ + const deepRollbackFolder = async ( + targetCommitId: string, + envId: string, + actorId: string, + actorType: ActorType, + projectId: string, + message?: string + ) => { + await folderCommitDAL.transaction(async (tx) => { + const targetCommit = await folderCommitDAL.findById(targetCommitId, tx); + if (!targetCommit) { + throw new NotFoundError({ message: `No commit found for commit ID ${targetCommitId}` }); + } + + const checkpoint = await folderTreeCheckpointDAL.findNearestCheckpoint(targetCommit.commitId, envId, tx); + if (!checkpoint) { + throw new NotFoundError({ message: `No checkpoint found for commit ID ${targetCommitId}` }); + } + + const folderCheckpointCommits = await folderTreeCheckpointResourcesDAL.findByTreeCheckpointId(checkpoint.id, tx); + const folderCommits = await folderCommitDAL.findAllCommitsBetween({ + envId, + startCommitId: checkpoint.commitId.toString(), + tx + }); + + // Group commits by folderId and keep only the latest + const folderGroups = new Map(); + + if (folderCheckpointCommits && folderCheckpointCommits.length > 0) { + for (const commit of folderCheckpointCommits) { + if (commit.commitId > targetCommit.commitId) { + folderGroups.set(commit.folderId, { + commitId: commit.commitId, + id: commit.folderCommitId + }); + } + } + } + + if (folderCommits && folderCommits.length > 0) { + for (const commit of folderCommits) { + const { folderId, commitId, id } = commit; + const existingCommit = folderGroups.get(folderId); + + if ((!existingCommit || commitId > existingCommit.commitId) && commitId > targetCommit.commitId) { + folderGroups.set(folderId, { commitId, id }); + } + } + } + + const folderDiffs = new Map(); + + // Process each folder to determine differences + await Promise.all( + Array.from(folderGroups.entries()).map(async ([folderId, { id }]) => { + const previousCommit = await folderCommitDAL.findPreviousCommitTo( + folderId, + targetCommit.commitId.toString(), + tx + ); + if (previousCommit && previousCommit.id !== id) { + const diff = await compareFolderStates({ + currentCommitId: id, + targetCommitId: previousCommit.id, + tx + }); + if (diff?.length > 0) { + folderDiffs.set(folderId, diff); + } + } + }) + ); + + const foldersToDelete = new Set(); + + // Process all DELETE operations to build a complete set of folders to be deleted + for (const changes of folderDiffs.values()) { + for (const change of changes) { + if (change.changeType === ChangeType.DELETE && change.type === ResourceType.FOLDER) { + foldersToDelete.add(change.id); + } + } + } + + // Now, remove any folder that is being deleted from the folderDiffs map + // before applying any changes + for (const folderId of foldersToDelete) { + folderDiffs.delete(folderId); + } + + // Apply changes in hierarchical order + const folderIds = Array.from(folderDiffs.keys()); + const folders = await folderDAL.findFoldersByRootAndIds({ rootId: targetCommit.folderId, folderIds }, tx); + const sortedFolders = sortFoldersByHierarchy(folders); + + for (const folder of sortedFolders) { + const diff = folderDiffs.get(folder.id); + if (diff) { + await applyFolderStateDifferences({ + differences: diff, + actorInfo: { + actorType, + actorId, + message: message || "Deep rollback" + }, + folderId: folder.id, + projectId, + reconstructNewFolders: true, + reconstructUpToCommit: targetCommit.commitId.toString(), + tx + }); + } + } + }); + }; + + const getLatestCommit = async ({ + folderId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }: { + folderId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + }) => { + await checkProjectCommitReadPermission({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId + }); + return folderCommitDAL.findLatestCommit(folderId, projectId); + }; + + /** + * Revert changes made in a specific commit + */ + const revertCommitChanges = async ({ + commitId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + message = "Revert commit changes" + }: { + commitId: string; + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + message?: string; + }) => { + if (!permissionService) { + throw new Error("Permission service not initialized"); + } + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCommitsActions.PerformRollback, + ProjectPermissionSub.Commits + ); + // Check permissions first + await checkProjectCommitReadPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + }); + + // Get the commit to revert + const commitToRevert = await folderCommitDAL.findById(commitId, undefined, projectId); + if (!commitToRevert) { + throw new NotFoundError({ message: `Commit with ID ${commitId} not found` }); + } + + const previousCommit = await folderCommitDAL.findCommitBefore(commitToRevert.folderId, commitToRevert.commitId); + + if (!previousCommit) { + throw new BadRequestError({ message: "Cannot revert the first commit" }); + } + + // Calculate the changes needed to go from current commit back to the previous one + const inverseChanges = await compareFolderStates({ + currentCommitId: commitToRevert.id, + targetCommitId: previousCommit.id + }); + + const latestCommit = await folderCommitDAL.findLatestCommit(commitToRevert.folderId); + if (!latestCommit) { + throw new NotFoundError({ message: `Latest commit not found for folder ${commitToRevert.folderId}` }); + } + const currentState = await reconstructFolderState(latestCommit.id); + + const filteredChanges = inverseChanges.filter( + (change) => + ((change.changeType === ChangeType.DELETE || change.changeType === ChangeType.UPDATE) && + (currentState.some((c) => c.id === change.id) || currentState.some((c) => c.id === change.id))) || + (change.changeType === ChangeType.CREATE && + (currentState.every((c) => c.id !== change.id) || currentState.every((c) => c.id !== change.id))) + ); + + if (!filteredChanges || filteredChanges.length === 0) { + return { + success: true, + message: "No changes to revert", + originalCommitId: commitId + }; + } + + // Apply the changes to revert the commit + const revertResult = await applyFolderStateDifferences({ + differences: filteredChanges, + actorInfo: { + actorType: actor, + actorId, + message: message || `Reverted changes from commit ${commitId}` + }, + folderId: commitToRevert.folderId, + projectId, + reconstructNewFolders: true, + reconstructUpToCommit: commitToRevert.commitId.toString() + }); + + return { + success: true, + message: "Changes reverted successfully", + originalCommitId: commitId, + revertCommitId: latestCommit?.id, + changesReverted: revertResult.totalChanges + }; + }; + + return { + createCommit, + addCommitChange, + getCommitById, + getCommitsByFolderId, + getCommitChanges, + getCheckpointsByFolderId, + getLatestCheckpoint, + getFolderInitialChanges, + createFolderCheckpoint, + compareFolderStates, + applyFolderStateDifferences, + createFolderTreeCheckpoint, + deepRollbackFolder, + getCommitsCount, + getLatestCommit, + deepCompareFolder, + reconstructFolderState, + getCommitsForFolder, + revertCommitChanges + }; +}; + +export type TFolderCommitServiceFactory = ReturnType; diff --git a/backend/src/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal.ts b/backend/src/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal.ts new file mode 100644 index 000000000..58651a48a --- /dev/null +++ b/backend/src/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal.ts @@ -0,0 +1,44 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TFolderTreeCheckpointResources } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderTreeCheckpointResourcesDALFactory = ReturnType; + +type TFolderTreeCheckpointResourcesWithCommitId = TFolderTreeCheckpointResources & { + commitId: bigint; +}; + +export const folderTreeCheckpointResourcesDALFactory = (db: TDbClient) => { + const folderTreeCheckpointResourcesOrm = ormify(db, TableName.FolderTreeCheckpointResources); + + const findByTreeCheckpointId = async ( + folderTreeCheckpointId: string, + tx?: Knex + ): Promise => { + try { + const docs = await (tx || db.replicaNode())( + TableName.FolderTreeCheckpointResources + ) + .join( + TableName.FolderCommit, + `${TableName.FolderTreeCheckpointResources}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderTreeCheckpointId }, TableName.FolderTreeCheckpointResources)) + .select(selectAllTableCols(TableName.FolderTreeCheckpointResources)) + .select(db.ref("commitId").withSchema(TableName.FolderCommit).as("commitId")); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByTreeCheckpointId" }); + } + }; + + return { + ...folderTreeCheckpointResourcesOrm, + findByTreeCheckpointId + }; +}; diff --git a/backend/src/services/folder-tree-checkpoint/folder-tree-checkpoint-dal.ts b/backend/src/services/folder-tree-checkpoint/folder-tree-checkpoint-dal.ts new file mode 100644 index 000000000..cc0634e74 --- /dev/null +++ b/backend/src/services/folder-tree-checkpoint/folder-tree-checkpoint-dal.ts @@ -0,0 +1,79 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TFolderCommits, TFolderTreeCheckpoints } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TFolderTreeCheckpointDALFactory = ReturnType; + +type TreeCheckpointWithCommitInfo = TFolderTreeCheckpoints & { + commitId: bigint; +}; + +export const folderTreeCheckpointDALFactory = (db: TDbClient) => { + const folderTreeCheckpointOrm = ormify(db, TableName.FolderTreeCheckpoint); + + const findByCommitId = async (folderCommitId: string, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderTreeCheckpoint) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ folderCommitId }, TableName.FolderTreeCheckpoint)) + .select(selectAllTableCols(TableName.FolderTreeCheckpoint)) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindByCommitId" }); + } + }; + + const findNearestCheckpoint = async ( + folderCommitId: bigint, + envId: string, + tx?: Knex + ): Promise => { + try { + const nearestCheckpoint = await (tx || db.replicaNode())(TableName.FolderTreeCheckpoint) + .join( + TableName.FolderCommit, + `${TableName.FolderTreeCheckpoint}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(`${TableName.FolderCommit}.envId`, "=", envId) + .andWhere(`${TableName.FolderCommit}.commitId`, "<=", folderCommitId.toString()) + .select(selectAllTableCols(TableName.FolderTreeCheckpoint)) + .select(db.ref("commitId").withSchema(TableName.FolderCommit)) + .orderBy(`${TableName.FolderCommit}.commitId`, "desc") + .first(); + + return nearestCheckpoint; + } catch (error) { + throw new DatabaseError({ error, name: "FindNearestCheckpoint" }); + } + }; + + const findLatestByEnvId = async (envId: string, tx?: Knex): Promise => { + try { + const doc = await (tx || db.replicaNode())(TableName.FolderTreeCheckpoint) + .join( + TableName.FolderCommit, + `${TableName.FolderTreeCheckpoint}.folderCommitId`, + `${TableName.FolderCommit}.id` + ) + .where(`${TableName.FolderCommit}.envId`, "=", envId) + .orderBy(`${TableName.FolderTreeCheckpoint}.createdAt`, "desc") + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindLatestByEnvId" }); + } + }; + + return { + ...folderTreeCheckpointOrm, + findByCommitId, + findNearestCheckpoint, + findLatestByEnvId + }; +}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 1ff2c78d2..a793ecfab 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; +import { TListProjectGroupUsersDTO } from "@app/ee/services/group/group-types"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation @@ -42,7 +43,7 @@ type TGroupProjectServiceFactoryDep = { projectKeyDAL: Pick; projectRoleDAL: Pick; projectBotDAL: TProjectBotDALFactory; - groupDAL: Pick; + groupDAL: Pick; permissionService: Pick; }; @@ -471,11 +472,54 @@ export const groupProjectServiceFactory = ({ return groupMembership; }; + const listProjectGroupUsers = async ({ + id, + projectId, + offset, + limit, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + filter + }: TListProjectGroupUsersDTO) => { + const project = await projectDAL.findById(projectId); + + if (!project) { + throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + + const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + orgId: project.orgId, + groupId: id, + offset, + limit, + username, + search, + filter + }); + + return { users: members, totalCount }; + }; + return { addGroupToProject, updateGroupInProject, removeGroupFromProject, listGroupsInProject, - getGroupInProject + getGroupInProject, + listProjectGroupUsers }; }; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index fea12d3ee..879ca9fd3 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -28,6 +28,11 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { `${TableName.IdentityUniversalAuth}.id` ) .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) @@ -44,6 +49,10 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .select( db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), + db + .ref("accessTokenTrustedIps") + .withSchema(TableName.IdentityAliCloudAuth) + .as("accessTokenTrustedIpsAliCloud"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), @@ -62,6 +71,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { ...doc, trustedIpsUniversalAuth: doc.accessTokenTrustedIpsUa, trustedIpsGcpAuth: doc.accessTokenTrustedIpsGcp, + trustedIpsAliCloudAuth: doc.accessTokenTrustedIpsAliCloud, trustedIpsAwsAuth: doc.accessTokenTrustedIpsAws, trustedIpsAzureAuth: doc.accessTokenTrustedIpsAzure, trustedIpsKubernetesAuth: doc.accessTokenTrustedIpsK8s, diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index c5b57373d..7c8944f50 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -193,6 +193,7 @@ export const identityAccessTokenServiceFactory = ({ const trustedIpsMap: Record = { [IdentityAuthMethod.UNIVERSAL_AUTH]: identityAccessToken.trustedIpsUniversalAuth, [IdentityAuthMethod.GCP_AUTH]: identityAccessToken.trustedIpsGcpAuth, + [IdentityAuthMethod.ALICLOUD_AUTH]: identityAccessToken.trustedIpsAliCloudAuth, [IdentityAuthMethod.AWS_AUTH]: identityAccessToken.trustedIpsAwsAuth, [IdentityAuthMethod.OCI_AUTH]: identityAccessToken.trustedIpsOciAuth, [IdentityAuthMethod.AZURE_AUTH]: identityAccessToken.trustedIpsAzureAuth, diff --git a/backend/src/services/identity-access-token/identity-access-token-types.ts b/backend/src/services/identity-access-token/identity-access-token-types.ts index c97d2f40a..87adfa5dc 100644 --- a/backend/src/services/identity-access-token/identity-access-token-types.ts +++ b/backend/src/services/identity-access-token/identity-access-token-types.ts @@ -11,5 +11,9 @@ export type TIdentityAccessTokenJwtPayload = { oidc?: { claims: Record; }; + kubernetes?: { + namespace: string; + name: string; + }; }; }; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-dal.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-dal.ts new file mode 100644 index 000000000..a4ca50766 --- /dev/null +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-dal.ts @@ -0,0 +1,9 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityAliCloudAuthDALFactory = ReturnType; + +export const identityAliCloudAuthDALFactory = (db: TDbClient) => { + return ormify(db, TableName.IdentityAliCloudAuth); +}; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts new file mode 100644 index 000000000..ad357b4e9 --- /dev/null +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -0,0 +1,361 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import { AxiosError } from "axios"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +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"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentityAliCloudAuthDALFactory } from "./identity-alicloud-auth-dal"; +import { + TAliCloudGetUserResponse, + TAttachAliCloudAuthDTO, + TGetAliCloudAuthDTO, + TLoginAliCloudAuthDTO, + TRevokeAliCloudAuthDTO, + TUpdateAliCloudAuthDTO +} from "./identity-alicloud-auth-types"; + +type TIdentityAliCloudAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityAliCloudAuthDAL: Pick< + TIdentityAliCloudAuthDALFactory, + "findOne" | "transaction" | "create" | "updateById" | "delete" + >; + identityOrgMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; +}; + +export type TIdentityAliCloudAuthServiceFactory = ReturnType; + +export const identityAliCloudAuthServiceFactory = ({ + identityAccessTokenDAL, + identityAliCloudAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService +}: TIdentityAliCloudAuthServiceFactoryDep) => { + const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { + const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); + if (!identityAliCloudAuth) { + throw new NotFoundError({ + message: "Alibaba Cloud auth method not found for identity, did you configure Alibaba Cloud auth?" + }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityAliCloudAuth.identityId + }); + + const requestUrl = new URL("https://sts.aliyuncs.com"); + + for (const key of Object.keys(params)) { + requestUrl.searchParams.set(key, (params as Record)[key]); + } + + const { data } = await request.get(requestUrl.toString()).catch((err: AxiosError) => { + logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); + throw err; + }); + + if (identityAliCloudAuth.allowedArns) { + // In the future we could do partial checks for role ARNs + const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: Alibaba Cloud account ARN not allowed." + }); + } + + // Generate the token + const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAliCloudAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.ALICLOUD_AUTH + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAliCloudAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + return { + identityAliCloudAuth, + accessToken, + identityAccessToken, + identityMembershipOrg + }; + }; + + const attachAliCloudAuth = async ({ + identityId, + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin + }: TAttachAliCloudAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { + throw new BadRequestError({ + message: "Failed to add Alibaba Cloud Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAliCloudAuth = await identityAliCloudAuthDAL.transaction(async (tx) => { + const doc = await identityAliCloudAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + type: "iam", + allowedArns, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + return doc; + }); + return { ...identityAliCloudAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAliCloudAuth = async ({ + identityId, + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAliCloudAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { + throw new NotFoundError({ + message: "The identity does not have Alibaba Cloud Auth attached" + }); + } + + const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityAliCloudAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityAliCloudAuth.accessTokenTTL) > + (accessTokenMaxTTL || identityAliCloudAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAliCloudAuth = await identityAliCloudAuthDAL.updateById(identityAliCloudAuth.id, { + allowedArns, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedAliCloudAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getAliCloudAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAliCloudAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have Alibaba Cloud Auth attached" + }); + } + + const alicloudIdentityAuth = await identityAliCloudAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.orgId }; + }; + + const revokeIdentityAliCloudAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeAliCloudAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have Alibaba Cloud auth" + }); + } + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke Alibaba Cloud auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const revokedIdentityAliCloudAuth = await identityAliCloudAuthDAL.transaction(async (tx) => { + const deletedAliCloudAuth = await identityAliCloudAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.ALICLOUD_AUTH }, tx); + + return { ...deletedAliCloudAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityAliCloudAuth; + }; + + return { + login, + attachAliCloudAuth, + updateAliCloudAuth, + getAliCloudAuth, + revokeIdentityAliCloudAuth + }; +}; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts new file mode 100644 index 000000000..86133491e --- /dev/null +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts @@ -0,0 +1,45 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAliCloudAuthDTO = { + identityId: string; + Action: string; + Format: string; + Version: string; + AccessKeyId: string; + SignatureMethod: string; + Timestamp: string; + SignatureVersion: string; + SignatureNonce: string; + Signature: string; +}; + +export type TAttachAliCloudAuthDTO = { + identityId: string; + allowedArns: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateAliCloudAuthDTO = { + identityId: string; + allowedArns: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAliCloudAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeAliCloudAuthDTO = { + identityId: string; +} & Omit; + +export type TAliCloudGetUserResponse = { + Arn: string; +}; diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-validators.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-validators.ts new file mode 100644 index 000000000..80fcc444b --- /dev/null +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-validators.ts @@ -0,0 +1,25 @@ +import RE2 from "re2"; +import { z } from "zod"; + +const arnSchema = z + .string() + .refine( + (val) => new RE2("^acs:ram::[0-9]{16}:(user|role)/.*$").test(val), + "Invalid ARN format. Expected format: acs:ram::[0-9]{16}:(user|role)/*" + ); +export const validateArns = z + .string() + .trim() + .min(1, "Allowed ARNs required") + .max(500, "Input exceeds the maximum limit of 500 characters") + .transform((val) => { + if (!val) return []; + return val + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + }) + .refine((arr) => arr.every((name) => arnSchema.safeParse(name).success), { + message: "One or more ARNs are invalid" + }) + .transform((arr) => arr.join(", ")); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index fe7b24783..d6236e4ed 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -94,7 +94,9 @@ export const identityAwsAuthServiceFactory = ({ const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); - const region = headers.Authorization ? awsRegionFromHeader(headers.Authorization) : null; + + const authHeader = headers.Authorization || headers.authorization; + const region = authHeader ? awsRegionFromHeader(authHeader) : null; if (!isValidAwsRegion(region)) { throw new BadRequestError({ message: "Invalid AWS region" }); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts index 785b37bbc..9844c8a63 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -40,7 +40,8 @@ export type TAwsGetCallerIdentityHeaders = { "X-Amz-Date": string; "Content-Length": number; "x-amz-security-token": string; - Authorization: string; + Authorization?: string; + authorization?: string; }; export type TGetCallerIdentityResponse = { 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 34a28c6a6..1f89745a9 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 @@ -20,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"; @@ -33,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, @@ -70,21 +72,27 @@ export const identityKubernetesAuthServiceFactory = ({ const $gatewayProxyWrapper = async ( inputs: { gatewayId: string; - targetHost: string; - targetPort: number; + 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, @@ -95,7 +103,16 @@ export const identityKubernetesAuthServiceFactory = ({ ca: relayDetails.certChain, cert: relayDetails.certificate, key: relayDetails.privateKey.toString() - } + }, + // only needed for TCP protocol, because the gateway as reviewer will use the pod's CA cert for auth directly + ...(!inputs.reviewTokenThroughGateway + ? { + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: Boolean(inputs.caCert) + }) + } + : {}) } ); @@ -129,22 +146,36 @@ 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 = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); - let { kubernetesHost } = identityKubernetesAuth; - if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { - kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); - } + if (!host || !identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to 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 @@ -165,11 +196,10 @@ export const identityKubernetesAuthServiceFactory = ({ }, signal: AbortSignal.timeout(10000), timeout: 10000, - // if ca cert, rejectUnauthorized: true httpsAgent: new https.Agent({ ca: caCert, rejectUnauthorized: Boolean(caCert), - servername: kubernetesHost + servername }) } ) @@ -192,18 +222,110 @@ export const identityKubernetesAuthServiceFactory = ({ return res.data; }; - const [k8sHost, k8sPort] = kubernetesHost.split(":"); + const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); - const data = identityKubernetesAuth.gatewayId - ? await $gatewayProxyWrapper( + const res = await axios + .post( + `${host}:${port}/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.UseGatewayK8sServiceAccount + }, + signal: AbortSignal.timeout(10000), + timeout: 10000 + } ) - : 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) { + 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, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + if (!identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to 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" }); @@ -278,7 +400,13 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityKubernetesAuth.identityId, identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + kubernetes: { + namespace: targetNamespace, + name: targetName + } + } } as TIdentityAccessTokenJwtPayload, appCfg.AUTH_SECRET, // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error @@ -298,6 +426,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -384,6 +513,7 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityMembershipOrg.identityId, kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -410,6 +540,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -492,6 +623,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 12edd266f..269fa19e0 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; + kubernetesHost: string | null; caCert: string; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -23,9 +29,10 @@ export type TAttachKubernetesAuthDTO = { export type TUpdateKubernetesAuthDTO = { identityId: string; - kubernetesHost?: string; + kubernetesHost?: string | null; caCert?: string; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index 4928fd178..433f5ebd9 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { TableName, TIdentities, + TIdentityAlicloudAuths, TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, @@ -57,6 +58,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembership}.identityId`, `${TableName.IdentityGcpAuth}.identityId` ) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.IdentityProjectMembership}.identityId`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) .leftJoin( TableName.IdentityAwsAuth, `${TableName.IdentityProjectMembership}.identityId`, @@ -111,6 +117,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("type").as("projectType").withSchema(TableName.Project), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), @@ -267,6 +274,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId` ) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) .leftJoin( TableName.IdentityAwsAuth, `${TableName.Identity}.id`, @@ -319,6 +331,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("name").as("projectName").withSchema(TableName.Project), db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), @@ -346,6 +359,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { identityId, identityName, uaId, + alicloudId, awsId, gcpId, kubernetesId, @@ -367,6 +381,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { name: identityName, authMethods: buildAuthMethods({ uaId, + alicloudId, awsId, gcpId, kubernetesId, diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 53404788e..dec172e4e 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -122,9 +122,9 @@ export const identityUaServiceFactory = ({ } : { accessTokenTTL: identityUa.accessTokenPeriod, - // Setting Max TTL to 2 × period ensures that clients can always renew their token - // at least once, and matches client logic that checks if renewing would exceed Max TTL. - accessTokenMaxTTL: 2 * identityUa.accessTokenPeriod + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 }; const identityAccessToken = await identityUaDAL.transaction(async (tx) => { diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 3fa2482aa..3020d9c47 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -3,6 +3,7 @@ import { IdentityAuthMethod } from "@app/db/schemas"; export const buildAuthMethods = ({ uaId, gcpId, + alicloudId, awsId, kubernetesId, ociId, @@ -14,6 +15,7 @@ export const buildAuthMethods = ({ }: { uaId?: string; gcpId?: string; + alicloudId?: string; awsId?: string; kubernetesId?: string; ociId?: string; @@ -26,6 +28,7 @@ export const buildAuthMethods = ({ return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], ...[gcpId ? IdentityAuthMethod.GCP_AUTH : null], + ...[alicloudId ? IdentityAuthMethod.ALICLOUD_AUTH : null], ...[awsId ? IdentityAuthMethod.AWS_AUTH : null], ...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null], ...[ociId ? IdentityAuthMethod.OCI_AUTH : null], diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index af5537249..7cf51b9d8 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -3,6 +3,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName, + TIdentityAlicloudAuths, TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, @@ -53,6 +54,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityGcpAuth}.identityId` ) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) .leftJoin( TableName.IdentityAwsAuth, `${TableName.IdentityOrgMembership}.identityId`, @@ -99,6 +105,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), @@ -183,6 +190,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityGcpAuth}.identityId` ) + .leftJoin( + TableName.IdentityAliCloudAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityAliCloudAuth}.identityId` + ) .leftJoin( TableName.IdentityAwsAuth, "paginatedIdentity.identityId", @@ -236,6 +248,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), @@ -278,6 +291,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { id, orgId, uaId, + alicloudId, awsId, gcpId, jwtId, @@ -312,6 +326,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { name: identityName, authMethods: buildAuthMethods({ uaId, + alicloudId, awsId, gcpId, kubernetesId, @@ -459,6 +474,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), @@ -502,6 +518,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { total_count, id, uaId, + alicloudId, awsId, gcpId, jwtId, @@ -536,6 +553,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { name: identityName, authMethods: buildAuthMethods({ uaId, + alicloudId, awsId, gcpId, kubernetesId, diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index c4f0856a1..5730c0d92 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -212,7 +212,7 @@ export const orgDALFactory = (db: TDbClient) => { // special query const findAllOrgsByUserId = async ( userId: string - ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => { + ): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]> => { try { const org = (await db .replicaNode()(TableName.OrgMembership) @@ -234,6 +234,7 @@ export const orgDALFactory = (db: TDbClient) => { }) .select(selectAllTableCols(TableName.Organization)) .select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole")) + .select(db.ref("status").withSchema(TableName.OrgMembership).as("userStatus")) .select( db.raw(` CASE @@ -242,7 +243,7 @@ export const orgDALFactory = (db: TDbClient) => { ELSE '' END as "orgAuthMethod" `) - )) as (TOrganizations & { orgAuthMethod: string; userRole: string })[]; + )) as (TOrganizations & { orgAuthMethod: string; userRole: string; userStatus: string })[]; return org; } catch (error) { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index bfd24e639..031b906a6 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -183,7 +183,9 @@ export const orgServiceFactory = ({ * */ const findAllOrganizationOfUser = async (userId: string) => { const orgs = await orgDAL.findAllOrgsByUserId(userId); - return orgs; + + // Filter out orgs where the membership object is an invitation + return orgs.filter((org) => org.userStatus !== "invited"); }; /* * Get all workspace members @@ -835,16 +837,22 @@ export const orgServiceFactory = ({ // if the user doesn't exist we create the user with the email if (!inviteeUser) { - inviteeUser = await userDAL.create( - { - isAccepted: false, - email: inviteeEmail, - username: inviteeEmail, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } } const inviteeUserId = inviteeUser?.id; @@ -1203,8 +1211,8 @@ export const orgServiceFactory = ({ subjectLine: "Infisical organization invitation", recipients: [el.email], substitutions: { - inviterFirstName: invitingUser.firstName, - inviterUsername: invitingUser.email, + inviterFirstName: invitingUser?.firstName, + inviterUsername: invitingUser?.email, organizationName: org?.name, email: el.email, organizationId: org?.id.toString(), diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 54bef02d1..7f733503c 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -12,7 +12,7 @@ import { TProjectsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { ActorType } from "../auth/auth-type"; import { Filter, ProjectFilterType, SearchProjectSortBy } from "./project-types"; @@ -475,6 +475,16 @@ export const projectDALFactory = (db: TDbClient) => { return { docs, totalCount: Number(docs?.[0]?.count ?? 0) }; }; + const findProjectByEnvId = async (envId: string, tx?: Knex) => { + const project = await (tx || db.replicaNode())(TableName.Project) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ id: envId }, TableName.Environment)) + .select(selectAllTableCols(TableName.Project)) + .first(); + return project; + }; + const countOfOrgProjects = async (orgId: string | null, tx?: Knex) => { try { const doc = await (tx || db.replicaNode())(TableName.Project) @@ -504,6 +514,7 @@ export const projectDALFactory = (db: TDbClient) => { checkProjectUpgradeStatus, getProjectFromSplitId, searchProjects, + findProjectByEnvId, countOfOrgProjects }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 6b132f654..680b0187c 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -30,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"; @@ -165,7 +165,7 @@ type TProjectServiceFactoryDep = { sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; - licenseService: Pick; + licenseService: Pick; queueService: Pick; smtpService: Pick; orgDAL: Pick; @@ -259,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) { @@ -493,6 +494,10 @@ export const projectServiceFactory = ({ ); } + // no need to invalidate if there was no limit + if (plan.workspaceLimit) { + await licenseService.invalidateGetPlan(organization.id); + } return { ...project, environments: envs, @@ -666,7 +671,8 @@ export const projectServiceFactory = ({ enforceCapitalization: update.autoCapitalization, hasDeleteProtection: update.hasDeleteProtection, slug: update.slug, - secretSharing: update.secretSharing + secretSharing: update.secretSharing, + showSnapshotsLegacy: update.showSnapshotsLegacy }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index be052f1cb..8ef72492a 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -94,6 +94,7 @@ export type TUpdateProjectDTO = { hasDeleteProtection?: boolean; slug?: string; secretSharing?: boolean; + showSnapshotsLegacy?: boolean; }; } & Omit; diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index e136c5a50..7dfeaddcf 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -488,6 +488,75 @@ export const secretFolderDALFactory = (db: TDbClient) => { } }; + const findFoldersByRootAndIds = async ({ rootId, folderIds }: { rootId: string; folderIds: string[] }, tx?: Knex) => { + try { + // First, get all descendant folders of rootId + const descendants = await (tx || db.replicaNode()) + .withRecursive("descendants", (qb) => + qb + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw("0 as depth"), + db.raw(`'/' as path`), + db.ref(`${TableName.Environment}.slug`).as("environment") + ) + .from(TableName.SecretFolder) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .where(`${TableName.SecretFolder}.id`, rootId) + .union((un) => { + void un + .select( + selectAllTableCols(TableName.SecretFolder), + db.raw("descendants.depth + 1 as depth"), + db.raw( + `CONCAT( + CASE WHEN descendants.path = '/' THEN '' ELSE descendants.path END, + CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' ELSE CONCAT('/', secret_folders.name) END + )` + ), + db.ref("descendants.environment") + ) + .from(TableName.SecretFolder) + .where(`${TableName.SecretFolder}.isReserved`, false) + .join("descendants", `${TableName.SecretFolder}.parentId`, "descendants.id"); + }) + ) + .select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*") + .from("descendants") + .whereIn(`id`, folderIds) + .orderBy("depth") + .orderBy(`name`); + + return descendants; + } catch (error) { + throw new DatabaseError({ error, name: "FindFoldersByRootAndIds" }); + } + }; + + const findByParentId = async (parentId: string, tx?: Knex) => { + try { + const folders = await (tx || db.replicaNode())(TableName.SecretFolder) + .where({ parentId }) + .andWhere({ isReserved: false }) + .select(selectAllTableCols(TableName.SecretFolder)); + return folders; + } catch (error) { + throw new DatabaseError({ error, name: "findByParentId" }); + } + }; + + const findByEnvId = async (envId: string, tx?: Knex) => { + try { + const folders = await (tx || db.replicaNode())(TableName.SecretFolder) + .where({ envId }) + .andWhere({ isReserved: false }) + .select(selectAllTableCols(TableName.SecretFolder)); + return folders; + } catch (error) { + throw new DatabaseError({ error, name: "findByEnvId" }); + } + }; + return { ...secretFolderOrm, update, @@ -499,6 +568,9 @@ export const secretFolderDALFactory = (db: TDbClient) => { findClosestFolder, findByProjectId, findByMultiEnv, - findByEnvsDeep + findByEnvsDeep, + findByParentId, + findByEnvId, + findFoldersByRootAndIds }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 842eb2bb7..5722734f8 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -10,6 +10,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; +import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal"; @@ -29,7 +30,8 @@ type TSecretFolderServiceFactoryDep = { snapshotService: Pick; folderDAL: TSecretFolderDALFactory; projectEnvDAL: Pick; - folderVersionDAL: TSecretFolderVersionDALFactory; + folderVersionDAL: Pick; + folderCommitService: Pick; projectDAL: Pick; }; @@ -41,6 +43,7 @@ export const secretFolderServiceFactory = ({ permissionService, projectEnvDAL, folderVersionDAL, + folderCommitService, projectDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ @@ -111,15 +114,33 @@ export const secretFolderServiceFactory = ({ }); parentFolderId = newFolders.at(-1)?.id as string; const docs = await folderDAL.insertMany(newFolders, tx); - await folderVersionDAL.insertMany( + const folderVersions = await folderVersionDAL.insertMany( docs.map((doc) => ({ name: doc.name, envId: doc.envId, version: doc.version, - folderId: doc.id + folderId: doc.id, + description: doc.description })), tx ); + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder created", + folderId: parentFolderId, + changes: folderVersions.map((fv) => ({ + type: CommitType.ADD, + folderVersionId: fv.id + })) + }, + tx + ); } } @@ -127,12 +148,32 @@ export const secretFolderServiceFactory = ({ { name, envId: env.id, version: 1, parentId: parentFolderId, description }, tx ); - await folderVersionDAL.create( + const folderVersion = await folderVersionDAL.create( { name: doc.name, envId: doc.envId, version: doc.version, - folderId: doc.id + folderId: doc.id, + description: doc.description + }, + tx + ); + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder created", + folderId: parentFolderId, + changes: [ + { + type: CommitType.ADD, + folderVersionId: folderVersion.id + } + ] }, tx ); @@ -225,12 +266,33 @@ export const secretFolderServiceFactory = ({ { name, description }, tx ); - await folderVersionDAL.create( + const folderVersion = await folderVersionDAL.create( { name: doc.name, envId: doc.envId, version: doc.version, - folderId: doc.id + folderId: doc.id, + description: doc.description + }, + tx + ); + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder updated", + folderId: parentFolder.id, + changes: [ + { + type: CommitType.ADD, + isUpdate: true, + folderVersionId: folderVersion.id + } + ] }, tx ); @@ -321,12 +383,33 @@ export const secretFolderServiceFactory = ({ { name, description }, tx ); - await folderVersionDAL.create( + const folderVersion = await folderVersionDAL.create( { name: doc.name, envId: doc.envId, version: doc.version, - folderId: doc.id + folderId: doc.id, + description: doc.description + }, + tx + ); + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder updated", + folderId: parentFolder.id, + changes: [ + { + type: CommitType.ADD, + isUpdate: true, + folderVersionId: folderVersion.id + } + ] }, tx ); @@ -381,7 +464,31 @@ export const secretFolderServiceFactory = ({ }, tx ); + if (!doc) throw new NotFoundError({ message: `Failed to delete folder with ID '${idOrName}', not found` }); + + const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx); + + await folderCommitService.createCommit( + { + actor: { + type: actor, + metadata: { + id: actorId + } + }, + message: "Folder deleted", + folderId: parentFolder.id, + changes: [ + { + type: CommitType.DELETE, + folderVersionId: folderVersions[doc.id].id, + folderId: doc.id + } + ] + }, + tx + ); return doc; }); @@ -665,6 +772,45 @@ export const secretFolderServiceFactory = ({ return environmentFolders; }; + const getFolderVersionsByIds = async ({ + folderId, + folderVersions + }: { + folderId: string; + folderVersions: string[]; + }) => { + const versions = await folderVersionDAL.find({ + folderId, + $in: { + version: folderVersions.map((v) => Number.parseInt(v, 10)) + } + }); + return versions; + }; + + const getFolderVersions = async ( + change: { + folderVersion?: string; + isUpdate?: boolean; + changeType?: string; + }, + fromVersion: string, + folderId: string + ) => { + const currentVersion = change.folderVersion || "1"; + // eslint-disable-next-line no-await-in-loop + const versions = await getFolderVersionsByIds({ + folderId, + folderVersions: + change.isUpdate || change.changeType === ChangeType.UPDATE ? [currentVersion, fromVersion] : [currentVersion] + }); + return versions.map((v) => ({ + version: v.version?.toString() || "1", + name: v.name, + description: v.description + })); + }; + return { createFolder, updateFolder, @@ -675,6 +821,8 @@ export const secretFolderServiceFactory = ({ getProjectFolderCount, getFoldersMultiEnv, getFoldersDeepByEnvs, - getProjectEnvironmentsFolders + getProjectEnvironmentsFolders, + getFolderVersionsByIds, + getFolderVersions }; }; diff --git a/backend/src/services/secret-folder/secret-folder-version-dal.ts b/backend/src/services/secret-folder/secret-folder-version-dal.ts index 78186333d..46ff49692 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -43,7 +43,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { const docs: Array = await (tx || db.replicaNode())( TableName.SecretFolderVersion ) - .whereIn("folderId", folderIds) + .whereIn(`${TableName.SecretFolderVersion}.folderId`, folderIds) .join( (tx || db)(TableName.SecretFolderVersion) .groupBy("folderId") @@ -85,6 +85,8 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) .join("folder_cte", "folder_cte.id", `${TableName.SecretFolderVersion}.id`) .whereRaw(`folder_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + // Projects with version >= 3 will require to have all folder versions for PIT + .andWhere(`${TableName.Project}.version`, "<", 3) .delete(); } catch (error) { throw new DatabaseError({ @@ -95,5 +97,107 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret folder versions completed`); }; - return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId, pruneExcessVersions }; + // Get latest versions by folderIds + const getLatestFolderVersions = async (folderIds: string[], tx?: Knex): Promise> => { + if (!folderIds.length) return []; + + const knexInstance = tx || db.replicaNode(); + return knexInstance(TableName.SecretFolderVersion) + .whereIn(`${TableName.SecretFolderVersion}.folderId`, folderIds) + .join( + knexInstance(TableName.SecretFolderVersion) + .groupBy("folderId") + .max("version") + .select("folderId") + .as("latestVersion"), + (bd) => { + bd.on(`${TableName.SecretFolderVersion}.folderId`, "latestVersion.folderId").andOn( + `${TableName.SecretFolderVersion}.version`, + "latestVersion.max" + ); + } + ); + }; + + // Get specific versions and update with max version + const getSpecificFolderVersionsWithLatest = async ( + versionIds: string[], + tx?: Knex + ): Promise> => { + if (!versionIds.length) return []; + + const knexInstance = tx || db.replicaNode(); + + // Get specific versions + const specificVersions = await knexInstance(TableName.SecretFolderVersion).whereIn("id", versionIds); + + // Get folderIds from these versions + const specificFolderIds = [...new Set(specificVersions.map((v) => v.folderId).filter(Boolean))]; + + if (!specificFolderIds.length) return specificVersions; + + // Get max versions for these folderIds + const maxVersionsQuery = await knexInstance(TableName.SecretFolderVersion) + .whereIn("folderId", specificFolderIds) + .groupBy("folderId") + .select("folderId") + .max("version", { as: "maxVersion" }); + + // Create lookup map for max versions + const maxVersionMap = maxVersionsQuery.reduce>((acc, item) => { + if (item.maxVersion) { + acc[item.folderId] = item.maxVersion; + } + return acc; + }, {}); + + // Replace version with max version + return specificVersions.map((version) => ({ + ...version, + version: maxVersionMap[version.folderId] || version.version + })); + }; + + const findByIdsWithLatestVersion = async (folderIds: string[], versionIds?: string[], tx?: Knex) => { + try { + if (!folderIds.length && (!versionIds || !versionIds.length)) return {}; + + // Run both queries in parallel + const [latestVersions, specificVersionsWithLatest] = await Promise.all([ + folderIds.length ? getLatestFolderVersions(folderIds, tx) : [], + versionIds?.length ? getSpecificFolderVersionsWithLatest(versionIds, tx) : [] + ]); + + const allDocs = [...latestVersions, ...specificVersionsWithLatest]; + + // Convert array to record with folderId as key + return allDocs.reduce>( + (prev, curr) => ({ ...prev, [curr.folderId || ""]: curr }), + {} + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" }); + } + }; + + const findLatestVersion = async (folderId: string, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.SecretFolderVersion) + .where(`${TableName.SecretFolderVersion}.folderId`, folderId) + .select(selectAllTableCols(TableName.SecretFolderVersion)) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "findLatestVersion" }); + } + }; + + return { + ...secretFolderVerOrm, + findLatestFolderVersions, + findLatestVersionByFolderId, + pruneExcessVersions, + findByIdsWithLatestVersion, + findLatestVersion + }; }; 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-devops/azure-devops-sync-constants.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts new file mode 100644 index 000000000..5cce286c4 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const AZURE_DEVOPS_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Azure DevOps", + destination: SecretSync.AzureDevOps, + connection: AppConnection.AzureDevOps, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts new file mode 100644 index 000000000..e2447736b --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-fns.ts @@ -0,0 +1,233 @@ +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AzureDevOpsConnectionMethod } from "@app/services/app-connection/azure-devops/azure-devops-enums"; +import { getAzureDevopsConnection } from "@app/services/app-connection/azure-devops/azure-devops-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TAzureDevOpsSyncWithCredentials } from "./azure-devops-sync-types"; + +type TAzureDevOpsSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +interface AzureDevOpsVariableGroup { + id: number; + name: string; + description: string; + type: string; + variables: Record; + variableGroupProjectReferences: Array<{ + description: string; + name: string; + projectReference: { id: string; name: string }; + }>; +} + +interface AzureDevOpsVariableGroupList { + count: number; + value: AzureDevOpsVariableGroup[]; +} + +export const azureDevOpsSyncFactory = ({ kmsService, appConnectionDAL }: TAzureDevOpsSyncFactoryDeps) => { + const getConnectionAuth = async (secretSync: TAzureDevOpsSyncWithCredentials) => { + const { credentials } = secretSync.connection; + const isOAuth = secretSync.connection.method === AzureDevOpsConnectionMethod.OAuth; + + const { orgName } = credentials; + if (!orgName) { + throw new BadRequestError({ + message: "Azure DevOps: organization name is required" + }); + } + + const accessToken = await getAzureDevopsConnection(secretSync.connectionId, appConnectionDAL, kmsService); + + return { accessToken, orgName, isOAuth }; + }; + + const getAuthHeader = (accessToken: string, isOAuth: boolean) => { + if (isOAuth) { + return `Bearer ${accessToken}`; + } + const basicAuth = Buffer.from(`:${accessToken}`).toString("base64"); + return `Basic ${basicAuth}`; + }; + + const $getEnvGroupId = async ( + accessToken: string, + orgName: string, + projectId: string, + environmentName: string, + isOAuth: boolean + ) => { + const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/${encodeURIComponent(projectId)}/_apis/distributedtask/variablegroups?api-version=7.1`; + const response = await request.get(url, { + headers: { + Authorization: getAuthHeader(accessToken, isOAuth) + } + }); + + for (const group of response.data.value) { + if (group.name === environmentName) { + return { groupId: group.id.toString(), groupName: group.name }; + } + } + return { groupId: "", groupName: "" }; + }; + + const syncSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials, secretMap: TSecretMap) => { + if (!secretSync.destinationConfig.devopsProjectId) { + throw new BadRequestError({ + message: "Azure DevOps: project ID is required" + }); + } + + if (!secretSync.environment?.name) { + throw new BadRequestError({ + message: "Azure DevOps: environment name is required" + }); + } + + const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync); + + const { groupId, groupName } = await $getEnvGroupId( + accessToken, + orgName, + secretSync.destinationConfig.devopsProjectId, + secretSync.environment.name, + isOAuth + ); + + const variables: Record = {}; + for (const [key, secret] of Object.entries(secretMap)) { + if (secret?.value !== undefined) { + variables[key] = { value: secret.value, isSecret: true }; + } + } + + if (!groupId) { + // Create new variable group - API endpoint is organization-level + const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups?api-version=7.1`; + + await request.post( + url, + { + name: secretSync.environment.name, + description: secretSync.environment.name, + type: "Vsts", + variables, + variableGroupProjectReferences: [ + { + description: secretSync.environment.name, + name: secretSync.environment.name, + projectReference: { + id: secretSync.destinationConfig.devopsProjectId, + name: secretSync.destinationConfig.devopsProjectId + } + } + ] + }, + { + headers: { + Authorization: getAuthHeader(accessToken, isOAuth), + "Content-Type": "application/json" + } + } + ); + } else { + const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.1`; + + await request.put( + url, + { + name: groupName, + description: groupName, + type: "Vsts", + variables, + variableGroupProjectReferences: [ + { + description: groupName, + name: groupName, + projectReference: { + id: secretSync.destinationConfig.devopsProjectId, + name: secretSync.destinationConfig.devopsProjectId + } + } + ] + }, + { + headers: { + Authorization: getAuthHeader(accessToken, isOAuth), + "Content-Type": "application/json" + } + } + ); + } + }; + + const removeSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials) => { + const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync); + + const { groupId } = await $getEnvGroupId( + accessToken, + orgName, + secretSync.destinationConfig.devopsProjectId, + secretSync.environment?.name || "", + isOAuth + ); + + if (groupId) { + // Delete the variable group entirely using the DELETE API + const deleteUrl = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/distributedtask/variablegroups/${groupId}?projectIds=${secretSync.destinationConfig.devopsProjectId}&api-version=7.1`; + + await request.delete(deleteUrl, { + headers: { + Authorization: getAuthHeader(accessToken, isOAuth) + } + }); + } + }; + + const getSecrets = async (secretSync: TAzureDevOpsSyncWithCredentials) => { + const { accessToken, orgName, isOAuth } = await getConnectionAuth(secretSync); + + const { groupId } = await $getEnvGroupId( + accessToken, + orgName, + secretSync.destinationConfig.devopsProjectId, + secretSync.environment?.name || "", + isOAuth + ); + + const secretMap: TSecretMap = {}; + + if (groupId) { + const url = `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${orgName}/_apis/distributedtask/variablegroups/${groupId}?api-version=7.1`; + const response = await request.get(url, { + headers: { + Authorization: getAuthHeader(accessToken, isOAuth) + } + }); + + if (response?.data?.variables) { + Object.entries(response.data.variables).forEach(([key, variable]) => { + secretMap[key] = { + value: variable.value || "" + }; + }); + } + } + + return secretMap; + }; + + return { + syncSecrets, + removeSecrets, + getSecrets + }; +}; diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts new file mode 100644 index 000000000..71c10ecba --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +export const AzureDevOpsSyncDestinationConfigSchema = z.object({ + devopsProjectId: z + .string() + .min(1, "Project ID required") + .describe(SecretSyncs.DESTINATION_CONFIG.AZURE_DEVOPS?.devopsProjectId || "Azure DevOps Project ID"), + devopsProjectName: z + .string() + .min(1, "Project name required") + .describe(SecretSyncs.DESTINATION_CONFIG.AZURE_DEVOPS?.devopsProjectName || "Azure DevOps Project Name") +}); + +const AzureDevOpsSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const AzureDevOpsSyncSchema = BaseSecretSyncSchema(SecretSync.AzureDevOps, AzureDevOpsSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.AzureDevOps), + destinationConfig: AzureDevOpsSyncDestinationConfigSchema +}); + +export const CreateAzureDevOpsSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.AzureDevOps, + AzureDevOpsSyncOptionsConfig +).extend({ + destinationConfig: AzureDevOpsSyncDestinationConfigSchema +}); + +export const UpdateAzureDevOpsSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.AzureDevOps, + AzureDevOpsSyncOptionsConfig +).extend({ + destinationConfig: AzureDevOpsSyncDestinationConfigSchema.optional() +}); + +export const AzureDevOpsSyncListItemSchema = z.object({ + name: z.literal("Azure DevOps"), + connection: z.literal(AppConnection.AzureDevOps), + destination: z.literal(SecretSync.AzureDevOps), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts new file mode 100644 index 000000000..005929d22 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { TAzureDevOpsConnection } from "@app/services/app-connection/azure-devops/azure-devops-types"; + +import { + AzureDevOpsSyncDestinationConfigSchema, + AzureDevOpsSyncListItemSchema, + AzureDevOpsSyncSchema, + CreateAzureDevOpsSyncSchema +} from "./azure-devops-sync-schemas"; + +export type TAzureDevOpsSync = z.infer; + +export type TAzureDevOpsSyncInput = z.infer; + +export type TAzureDevOpsSyncListItem = z.infer; + +export type TAzureDevOpsSyncDestinationConfig = z.infer; + +export type TAzureDevOpsSyncWithCredentials = TAzureDevOpsSync & { + connection: TAzureDevOpsConnection; +}; diff --git a/backend/src/services/secret-sync/azure-devops/index.ts b/backend/src/services/secret-sync/azure-devops/index.ts new file mode 100644 index 000000000..404757734 --- /dev/null +++ b/backend/src/services/secret-sync/azure-devops/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-devops-sync-constants"; +export * from "./azure-devops-sync-fns"; +export * from "./azure-devops-sync-schemas"; +export * from "./azure-devops-sync-types"; 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-enums.ts b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts index 348d2bfa5..389070a9a 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts @@ -1,3 +1,63 @@ export enum GcpSyncScope { - Global = "global" + Global = "global", + Region = "region" +} + +export enum GCPSecretManagerLocation { + // Asia Pacific + ASIA_SOUTHEAST3 = "asia-southeast3", // Bangkok + ASIA_SOUTH2 = "asia-south2", // Delhi + ASIA_EAST2 = "asia-east2", // Hong Kong + ASIA_SOUTHEAST2 = "asia-southeast2", // Jakarta + AUSTRALIA_SOUTHEAST2 = "australia-southeast2", // Melbourne + ASIA_SOUTH1 = "asia-south1", // Mumbai + ASIA_NORTHEAST2 = "asia-northeast2", // Osaka + ASIA_NORTHEAST3 = "asia-northeast3", // Seoul + ASIA_SOUTHEAST1 = "asia-southeast1", // Singapore + AUSTRALIA_SOUTHEAST1 = "australia-southeast1", // Sydney + ASIA_EAST1 = "asia-east1", // Taiwan + ASIA_NORTHEAST1 = "asia-northeast1", // Tokyo + + // Europe + EUROPE_WEST1 = "europe-west1", // Belgium + EUROPE_WEST10 = "europe-west10", // Berlin + EUROPE_NORTH1 = "europe-north1", // Finland + EUROPE_NORTH2 = "europe-north2", // Stockholm + EUROPE_WEST3 = "europe-west3", // Frankfurt + EUROPE_WEST2 = "europe-west2", // London + EUROPE_SOUTHWEST1 = "europe-southwest1", // Madrid + EUROPE_WEST8 = "europe-west8", // Milan + EUROPE_WEST4 = "europe-west4", // Netherlands + EUROPE_WEST12 = "europe-west12", // Turin + EUROPE_WEST9 = "europe-west9", // Paris + EUROPE_CENTRAL2 = "europe-central2", // Warsaw + EUROPE_WEST6 = "europe-west6", // Zurich + + // North America + US_CENTRAL1 = "us-central1", // Iowa + US_WEST4 = "us-west4", // Las Vegas + US_WEST2 = "us-west2", // Los Angeles + NORTHAMERICA_SOUTH1 = "northamerica-south1", // Mexico + NORTHAMERICA_NORTHEAST1 = "northamerica-northeast1", // Montréal + US_EAST4 = "us-east4", // Northern Virginia + US_CENTRAL2 = "us-central2", // Oklahoma + US_WEST1 = "us-west1", // Oregon + US_WEST3 = "us-west3", // Salt Lake City + US_EAST1 = "us-east1", // South Carolina + NORTHAMERICA_NORTHEAST2 = "northamerica-northeast2", // Toronto + US_EAST5 = "us-east5", // Columbus + US_SOUTH1 = "us-south1", // Dallas + US_WEST8 = "us-west8", // Phoenix + + // South America + SOUTHAMERICA_EAST1 = "southamerica-east1", // São Paulo + SOUTHAMERICA_WEST1 = "southamerica-west1", // Santiago + + // Middle East + ME_CENTRAL2 = "me-central2", // Dammam + ME_CENTRAL1 = "me-central1", // Doha + ME_WEST1 = "me-west1", // Tel Aviv + + // Africa + AFRICA_SOUTH1 = "africa-south1" // Johannesburg } 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..d51383fef 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts @@ -4,6 +4,7 @@ import { request } from "@app/lib/config/request"; import { logger } from "@app/lib/logger"; import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { GcpSyncScope } from "@app/services/secret-sync/gcp/gcp-sync-enums"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncError } from "../secret-sync-errors"; @@ -15,9 +16,17 @@ import { TGcpSyncWithCredentials } from "./gcp-sync-types"; -const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { +const getProjectUrl = (secretSync: TGcpSyncWithCredentials) => { const { destinationConfig } = secretSync; + if (destinationConfig.scope === GcpSyncScope.Global) { + return `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}`; + } + + return `https://secretmanager.${destinationConfig.locationId}.rep.googleapis.com/v1/projects/${destinationConfig.projectId}/locations/${destinationConfig.locationId}`; +}; + +const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { let gcpSecrets: GCPSecret[] = []; const pageSize = 100; @@ -31,16 +40,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden }); // eslint-disable-next-line no-await-in-loop - const { data: secretsRes } = await request.get( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${secretSync.destinationConfig.projectId}/secrets`, - { - params, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + const { data: secretsRes } = await request.get(`${getProjectUrl(secretSync)}/secrets`, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); if (secretsRes.secrets) { gcpSecrets = gcpSecrets.concat(secretsRes.secrets); @@ -61,7 +67,7 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden try { const { data: secretLatest } = await request.get( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`, + `${getProjectUrl(secretSync)}/secrets/${key}/versions/latest:access`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -113,11 +119,14 @@ export const GcpSyncFns = { if (!(key in gcpSecrets)) { // case: create secret await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`, + `${getProjectUrl(secretSync)}/secrets`, { - replication: { - automatic: {} - } + replication: + destinationConfig.scope === GcpSyncScope.Global + ? { + automatic: {} + } + : undefined }, { params: { @@ -131,7 +140,7 @@ export const GcpSyncFns = { ); await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + `${getProjectUrl(secretSync)}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secretMap[key].value).toString("base64") @@ -155,7 +164,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) { @@ -163,15 +172,12 @@ export const GcpSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) continue; // case: delete secret - await request.delete( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); } else if (secretMap[key].value !== gcpSecrets[key]) { if (!secretMap[key].value) { logger.warn( @@ -180,7 +186,7 @@ export const GcpSyncFns = { } await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + `${getProjectUrl(secretSync)}/secrets/${key}:addVersion`, { payload: { data: Buffer.from(secretMap[key].value).toString("base64") @@ -212,21 +218,18 @@ export const GcpSyncFns = { }, removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, connection } = secretSync; + const { connection } = secretSync; const accessToken = await getGcpConnectionAuthToken(connection); const gcpSecrets = await getGcpSecrets(accessToken, secretSync); for await (const [key] of Object.entries(gcpSecrets)) { if (key in secretMap) { - await request.delete( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } + await request.delete(`${getProjectUrl(secretSync)}/secrets/${key}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" } - ); + }); } } } diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts index 0643c431a..875ceaf70 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts @@ -10,14 +10,33 @@ import { import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; import { SecretSync } from "../secret-sync-enums"; -import { GcpSyncScope } from "./gcp-sync-enums"; +import { GCPSecretManagerLocation, GcpSyncScope } from "./gcp-sync-enums"; const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -const GcpSyncDestinationConfigSchema = z.object({ - scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), - projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId) -}); +const GcpSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z + .object({ + scope: z.literal(GcpSyncScope.Global).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId) + }) + .describe( + JSON.stringify({ + title: "Global" + }) + ), + z + .object({ + scope: z.literal(GcpSyncScope.Region).describe(SecretSyncs.DESTINATION_CONFIG.GCP.scope), + projectId: z.string().min(1, "Project ID is required").describe(SecretSyncs.DESTINATION_CONFIG.GCP.projectId), + locationId: z.nativeEnum(GCPSecretManagerLocation).describe(SecretSyncs.DESTINATION_CONFIG.GCP.locationId) + }) + .describe( + JSON.stringify({ + title: "Region" + }) + ) +]); export const GcpSyncSchema = BaseSecretSyncSchema(SecretSync.GCPSecretManager, GcpSyncOptionsConfig).extend({ destination: z.literal(SecretSync.GCPSecretManager), 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/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-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 24f7d05f8..3cf940459 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -5,6 +5,7 @@ export enum SecretSync { GCPSecretManager = "gcp-secret-manager", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureDevOps = "azure-devops", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index dbf3a3699..bcda32967 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"; @@ -26,6 +26,7 @@ import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { ONEPASS_SYNC_LIST_OPTION, OnePassSyncFns } from "./1password"; import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFactory } from "./azure-app-configuration"; +import { AZURE_DEVOPS_SYNC_LIST_OPTION, azureDevOpsSyncFactory } from "./azure-devops"; import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; @@ -45,6 +46,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.GitHub]: GITHUB_SYNC_LIST_OPTION, [SecretSync.GCPSecretManager]: GCP_SYNC_LIST_OPTION, [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, + [SecretSync.AzureDevOps]: AZURE_DEVOPS_SYNC_LIST_OPTION, [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, @@ -68,13 +70,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 +88,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 +116,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 +163,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: @@ -152,6 +184,11 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).syncSecrets(secretSync, schemaSecretMap); + case SecretSync.AzureDevOps: + return azureDevOpsSyncFactory({ + appConnectionDAL, + kmsService + }).syncSecrets(secretSync, schemaSecretMap); case SecretSync.Databricks: return databricksSyncFactory({ appConnectionDAL, @@ -214,6 +251,12 @@ export const SecretSyncFns = { kmsService }).getSecrets(secretSync); break; + case SecretSync.AzureDevOps: + secretMap = await azureDevOpsSyncFactory({ + appConnectionDAL, + kmsService + }).getSecrets(secretSync); + break; case SecretSync.Databricks: return databricksSyncFactory({ appConnectionDAL, @@ -255,14 +298,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: @@ -283,6 +328,11 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).removeSecrets(secretSync, schemaSecretMap); + case SecretSync.AzureDevOps: + return azureDevOpsSyncFactory({ + appConnectionDAL, + kmsService + }).removeSecrets(secretSync); case SecretSync.Databricks: return databricksSyncFactory({ appConnectionDAL, diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 832c15bf8..dd329734a 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -8,6 +8,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.GCPSecretManager]: "GCP Secret Manager", [SecretSync.AzureKeyVault]: "Azure Key Vault", [SecretSync.AzureAppConfiguration]: "Azure App Configuration", + [SecretSync.AzureDevOps]: "Azure DevOps", [SecretSync.Databricks]: "Databricks", [SecretSync.Humanitec]: "Humanitec", [SecretSync.TerraformCloud]: "Terraform Cloud", @@ -27,6 +28,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.GCPSecretManager]: AppConnection.GCP, [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, + [SecretSync.AzureDevOps]: AppConnection.AzureDevOps, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, @@ -46,6 +48,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.GCPSecretManager]: SecretSyncPlanType.Regular, [SecretSync.AzureKeyVault]: SecretSyncPlanType.Regular, [SecretSync.AzureAppConfiguration]: SecretSyncPlanType.Regular, + [SecretSync.AzureDevOps]: SecretSyncPlanType.Regular, [SecretSync.Databricks]: SecretSyncPlanType.Regular, [SecretSync.Humanitec]: SecretSyncPlanType.Regular, [SecretSync.TerraformCloud]: SecretSyncPlanType.Regular, diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 6f627c24e..66e83661f 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -59,6 +59,7 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; export type TSecretSyncQueueFactory = ReturnType; @@ -94,6 +95,7 @@ type TSecretSyncQueueFactoryDep = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; + folderCommitService: Pick; licenseService: Pick; }; @@ -136,6 +138,7 @@ export const secretSyncQueueFactory = ({ secretVersionV2BridgeDAL, secretVersionTagV2BridgeDAL, resourceMetadataDAL, + folderCommitService, licenseService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -167,7 +170,8 @@ export const secretSyncQueueFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }); const $updateManySecretsRawFn = updateManySecretsRawFnFactory({ @@ -183,7 +187,8 @@ export const secretSyncQueueFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }); const $getInfisicalSecrets = async ( @@ -373,7 +378,7 @@ export const secretSyncQueueFactory = ({ if (Object.hasOwn(secretMap, key)) { // Only update secrets if the source value is not empty - if (value) { + if (value && value !== secretMap[key].value) { secretsToUpdate.push(secret); if (importBehavior === SecretSyncImportBehavior.PrioritizeDestination) importedSecretMap[key] = secretData; } 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/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 22f7848ad..ff355e5e1 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -60,6 +60,12 @@ import { TAzureAppConfigurationSyncListItem, TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration"; +import { + TAzureDevOpsSync, + TAzureDevOpsSyncInput, + TAzureDevOpsSyncListItem, + TAzureDevOpsSyncWithCredentials +} from "./azure-devops"; import { TAzureKeyVaultSync, TAzureKeyVaultSyncInput, @@ -100,6 +106,7 @@ export type TSecretSync = | TGcpSync | TAzureKeyVaultSync | TAzureAppConfigurationSync + | TAzureDevOpsSync | TDatabricksSync | THumanitecSync | TTerraformCloudSync @@ -118,6 +125,7 @@ export type TSecretSyncWithCredentials = | TGcpSyncWithCredentials | TAzureKeyVaultSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials + | TAzureDevOpsSyncWithCredentials | TDatabricksSyncWithCredentials | THumanitecSyncWithCredentials | TTerraformCloudSyncWithCredentials @@ -136,6 +144,7 @@ export type TSecretSyncInput = | TGcpSyncInput | TAzureKeyVaultSyncInput | TAzureAppConfigurationSyncInput + | TAzureDevOpsSyncInput | TDatabricksSyncInput | THumanitecSyncInput | TTerraformCloudSyncInput @@ -154,6 +163,7 @@ export type TSecretSyncListItem = | TGcpSyncListItem | TAzureKeyVaultSyncListItem | TAzureAppConfigurationSyncListItem + | TAzureDevOpsSyncListItem | TDatabricksSyncListItem | THumanitecSyncListItem | TTerraformCloudSyncListItem 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/secret-tag/secret-tag-dal.ts b/backend/src/services/secret-tag/secret-tag-dal.ts index 3b9151557..8768990fe 100644 --- a/backend/src/services/secret-tag/secret-tag-dal.ts +++ b/backend/src/services/secret-tag/secret-tag-dal.ts @@ -11,6 +11,7 @@ export const secretTagDALFactory = (db: TDbClient) => { const secretTagOrm = ormify(db, TableName.SecretTag); const secretJnTagOrm = ormify(db, TableName.JnSecretTag); const secretV2JnTagOrm = ormify(db, TableName.SecretV2JnTag); + const secretVersionV2TagOrm = ormify(db, TableName.SecretVersionV2Tag); const findManyTagsById = async (projectId: string, ids: string[], tx?: Knex) => { try { @@ -48,14 +49,39 @@ export const secretTagDALFactory = (db: TDbClient) => { } }; + const findSecretTagsByVersionId = async (versionId: string, tx?: Knex) => { + try { + const tags = await (tx || db.replicaNode())(TableName.SecretVersionV2Tag) + .where(`${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id`, versionId) + .select(selectAllTableCols(TableName.SecretVersionV2Tag)); + return tags; + } catch (error) { + throw new DatabaseError({ error, name: "Find all by version id" }); + } + }; + + const findSecretTagsBySecretId = async (secretId: string, tx?: Knex) => { + try { + const tags = await (tx || db.replicaNode())(TableName.SecretV2JnTag) + .where(`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`, secretId) + .select(selectAllTableCols(TableName.SecretV2JnTag)); + return tags; + } catch (error) { + throw new DatabaseError({ error, name: "Find all by secret id" }); + } + }; + return { ...secretTagOrm, saveTagsToSecret: secretJnTagOrm.insertMany, deleteTagsToSecret: secretJnTagOrm.delete, saveTagsToSecretV2: secretV2JnTagOrm.batchInsert, deleteTagsToSecretV2: secretV2JnTagOrm.delete, + saveTagsToSecretVersionV2: secretVersionV2TagOrm.insertMany, findSecretTagsByProjectId, deleteTagsManySecret, - findManyTagsById + findManyTagsById, + findSecretTagsByVersionId, + findSecretTagsBySecretId }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 6fdcadeff..1adfac22c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -8,6 +8,7 @@ import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { ActorType } from "../auth/auth-type"; +import { CommitType } from "../folder-commit/folder-commit-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; @@ -73,6 +74,7 @@ export const fnSecretBulkInsert = async ({ resourceMetadataDAL, secretTagDAL, secretVersionTagDAL, + folderCommitService, actor, tx }: TFnSecretBulkInsert) => { @@ -126,11 +128,36 @@ export const fnSecretBulkInsert = async ({ userActorId, identityActorId, actorType, + metadata: el.metadata ? JSON.stringify(el.metadata) : [], secretId: newSecretGroupedByKeyName[el.key][0].id })), tx ); + const commitChanges = secretVersions + .filter(({ type }) => type === SecretType.Shared) + .map((sv) => ({ + type: CommitType.ADD, + secretVersionId: sv.id + })); + + if (commitChanges.length > 0) { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actor?.actorId + } + }, + message: "Secret Created", + folderId, + changes: commitChanges + }, + tx + ); + } + await secretDAL.upsertSecretReferences( inputSecrets.map(({ references = [], key }) => ({ secretId: newSecretGroupedByKeyName[key][0].id, @@ -185,6 +212,7 @@ export const fnSecretBulkUpdate = async ({ orgId, secretDAL, secretVersionDAL, + folderCommitService, secretTagDAL, secretVersionTagDAL, resourceMetadataDAL, @@ -246,7 +274,7 @@ export const fnSecretBulkUpdate = async ({ userId, encryptedComment, version, - metadata, + metadata: metadata ? JSON.stringify(metadata) : [], reminderNote, encryptedValue, reminderRepeatDays, @@ -259,6 +287,7 @@ export const fnSecretBulkUpdate = async ({ ), tx ); + await secretDAL.upsertSecretReferences( inputSecrets .filter(({ data: { references } }) => Boolean(references)) @@ -329,6 +358,31 @@ export const fnSecretBulkUpdate = async ({ }, { tx } ); + + const commitChanges = secretVersions + .filter(({ type }) => type === SecretType.Shared) + .map((sv) => ({ + type: CommitType.ADD, + isUpdate: true, + secretVersionId: sv.id + })); + if (commitChanges.length > 0) { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actor?.actorId + } + }, + message: "Secret Updated", + folderId, + changes: commitChanges + }, + tx + ); + } + return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id })); }; @@ -337,8 +391,11 @@ export const fnSecretBulkDelete = async ({ inputSecrets, tx, actorId, + actorType, secretDAL, - secretQueueService + secretQueueService, + folderCommitService, + secretVersionDAL }: TFnSecretBulkDelete) => { const deletedSecrets = await secretDAL.deleteMany( inputSecrets.map(({ type, secretKey }) => ({ @@ -358,6 +415,35 @@ export const fnSecretBulkDelete = async ({ ) ); + const secretVersions = await secretVersionDAL.findLatestVersionMany( + folderId, + deletedSecrets.map(({ id }) => id), + tx + ); + + const commitChanges = deletedSecrets + .filter(({ type }) => type === SecretType.Shared) + .map(({ id }) => ({ + type: CommitType.DELETE, + secretVersionId: secretVersions[id].id + })); + if (commitChanges.length > 0) { + await folderCommitService.createCommit( + { + actor: { + type: actorType || ActorType.PLATFORM, + metadata: { + id: actorId + } + }, + message: "Secret Deleted", + folderId, + changes: commitChanges + }, + tx + ); + } + return deletedSecrets; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 1ef4a2d41..c4b5835c4 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -17,6 +17,7 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, + ProjectPermissionCommitsActions, ProjectPermissionSecretActions, ProjectPermissionSet, ProjectPermissionSub @@ -34,6 +35,7 @@ import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { ActorType } from "../auth/auth-type"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -90,6 +92,7 @@ type TSecretV2BridgeServiceFactoryDep = { secretVersionTagDAL: Pick; secretTagDAL: TSecretTagDALFactory; permissionService: Pick; + folderCommitService: Pick; projectEnvDAL: Pick; folderDAL: Pick< TSecretFolderDALFactory, @@ -124,6 +127,7 @@ export const secretV2BridgeServiceFactory = ({ projectEnvDAL, secretTagDAL, secretVersionDAL, + folderCommitService, folderDAL, permissionService, snapshotService, @@ -321,12 +325,14 @@ export const secretV2BridgeServiceFactory = ({ userId: inputSecret.type === SecretType.Personal ? actorId : null, tagIds: inputSecret.tagIds, references: nestedReferences, + metadata: secretMetadata ? JSON.stringify(secretMetadata) : [], secretMetadata } ], resourceMetadataDAL, secretDAL, secretVersionDAL, + folderCommitService, secretTagDAL, secretVersionTagDAL, actor: { @@ -510,6 +516,7 @@ export const secretV2BridgeServiceFactory = ({ folderId, orgId: actorOrgId, resourceMetadataDAL, + folderCommitService, inputSecrets: [ { filter: { id: secretId }, @@ -523,6 +530,7 @@ export const secretV2BridgeServiceFactory = ({ skipMultilineEncoding: inputSecret.skipMultilineEncoding, key: inputSecret.newSecretName || secretName, tags: inputSecret.tagIds, + metadata: secretMetadata ? JSON.stringify(secretMetadata) : [], secretMetadata, ...encryptedValue } @@ -650,6 +658,9 @@ export const secretV2BridgeServiceFactory = ({ projectId, folderId, actorId, + actorType: actor, + folderCommitService, + secretVersionDAL, secretDAL, secretQueueService, inputSecrets: [ @@ -1590,6 +1601,7 @@ export const secretV2BridgeServiceFactory = ({ orgId: actorOrgId, secretDAL, resourceMetadataDAL, + folderCommitService, secretVersionDAL, secretTagDAL, secretVersionTagDAL, @@ -1859,6 +1871,7 @@ export const secretV2BridgeServiceFactory = ({ const bulkUpdatedSecrets = await fnSecretBulkUpdate({ folderId, orgId: actorOrgId, + folderCommitService, tx, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; @@ -1928,6 +1941,7 @@ export const secretV2BridgeServiceFactory = ({ secretVersionDAL, secretTagDAL, secretVersionTagDAL, + folderCommitService, actor: { type: actor, actorId @@ -2061,6 +2075,8 @@ export const secretV2BridgeServiceFactory = ({ fnSecretBulkDelete({ secretDAL, secretQueueService, + folderCommitService, + secretVersionDAL, inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ secretKey, type: type || SecretType.Shared @@ -2068,6 +2084,7 @@ export const secretV2BridgeServiceFactory = ({ projectId, folderId, actorId, + actorType: actor, tx }) ); @@ -2159,15 +2176,25 @@ export const secretV2BridgeServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); + + const canRead = + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) || + permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" }); + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId: folder.projectId }); - const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors(secretId, folder.projectId, { - offset, - limit, - sort: [["createdAt", "desc"]] + const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({ + secretId, + projectId: folder.projectId, + findOpt: { + offset, + limit, + sort: [["createdAt", "desc"]] + } }); return secretVersions.map((el) => { const secretValueHidden = !hasSecretReadValueOrDescribePermission( @@ -2469,6 +2496,7 @@ export const secretV2BridgeServiceFactory = ({ tx, secretTagDAL, resourceMetadataDAL, + folderCommitService, secretVersionTagDAL, actor: { type: actor, @@ -2495,6 +2523,7 @@ export const secretV2BridgeServiceFactory = ({ folderId: destinationFolder.id, orgId: actorOrgId, resourceMetadataDAL, + folderCommitService, secretVersionDAL, secretDAL, tx, @@ -2840,6 +2869,76 @@ export const secretV2BridgeServiceFactory = ({ }; }; + const getSecretVersionsByIds = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + secretId, + secretVersionNumbers, + secretPath, + envId, + projectId + }: TGetSecretVersionsDTO & { + secretVersionNumbers: string[]; + secretPath: string; + envId: string; + projectId: string; + }) => { + const environment = await projectEnvDAL.findOne({ id: envId, projectId }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const canRead = + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback) || + permission.can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); + + if (!canRead) throw new ForbiddenRequestError({ message: "You do not have permission to read secret versions" }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + const secretVersions = await secretVersionDAL.findVersionsBySecretIdWithActors({ + secretId, + projectId, + secretVersions: secretVersionNumbers + }); + return secretVersions.map((el) => { + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: environment.slug, + secretPath, + secretName: el.key, + ...(el.tags?.length && { + secretTags: el.tags.map((tag) => tag.slug) + }) + } + ); + + return reshapeBridgeSecret( + projectId, + environment.slug, + secretPath, + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" + }, + secretValueHidden + ); + }); + }; + return { createSecret, deleteSecret, @@ -2858,6 +2957,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretReferenceTree, getSecretsByFolderMappings, getSecretById, - getAccessibleSecrets + getAccessibleSecrets, + getSecretVersionsByIds }; }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index f4a27d4c5..f4171b1a7 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -8,6 +8,7 @@ import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; @@ -178,9 +179,10 @@ export type TFnSecretBulkInsert = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + folderCommitService: Pick; actor?: { type: string; - actorId: string; + actorId?: string; }; }; @@ -206,9 +208,10 @@ export type TFnSecretBulkUpdate = { secretVersionDAL: Pick; secretTagDAL: Pick; secretVersionTagDAL: Pick; + folderCommitService: Pick; actor?: { type: string; - actorId: string; + actorId?: string; }; tx?: Knex; }; @@ -218,11 +221,14 @@ export type TFnSecretBulkDelete = { projectId: string; inputSecrets: Array<{ type: SecretType; secretKey: string }>; actorId: string; + actorType?: string; tx?: Knex; secretDAL: Pick; secretQueueService: { removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise; }; + folderCommitService: Pick; + secretVersionDAL: Pick; }; export type THandleReminderDTO = { diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index b54b073a6..9537b79e3 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -4,7 +4,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { SecretVersionsV2Schema, TableName, TSecretVersionsV2, TSecretVersionsV2Update } from "@app/db/schemas"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindOpt } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { QueueName } from "@app/queue"; @@ -138,7 +138,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { {} ); } catch (error) { - throw new DatabaseError({ error, name: "FindLatestVersinMany" }); + throw new DatabaseError({ error, name: "FindLatestVersionMany" }); } }; @@ -162,6 +162,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) .join("version_cte", "version_cte.id", `${TableName.SecretVersionV2}.id`) .whereRaw(`version_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + // Projects with version >= 3 will require to have all secret versions for PIT + .andWhere(`${TableName.Project}.version`, "<", 3) .delete(); } catch (error) { throw new DatabaseError({ @@ -172,13 +174,21 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { logger.info(`${QueueName.DailyResourceCleanUp}: pruning secret version v2 completed`); }; - const findVersionsBySecretIdWithActors = async ( - secretId: string, - projectId: string, - { offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt = {}, - tx?: Knex - ) => { + const findVersionsBySecretIdWithActors = async ({ + secretId, + projectId, + secretVersions, + findOpt = {}, + tx + }: { + secretId: string; + projectId: string; + secretVersions?: string[]; + findOpt?: TFindOpt; + tx?: Knex; + }) => { try { + const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt; const query = (tx || db)(TableName.SecretVersionV2) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) .leftJoin( @@ -189,22 +199,24 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( - TableName.SecretV2JnTag, - `${TableName.SecretV2}.id`, - `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + TableName.SecretVersionV2Tag, + `${TableName.SecretVersionV2}.id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id` ) .leftJoin( TableName.SecretTag, - `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) .where((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); void qb.where(`${TableName.ProjectMembership}.projectId`, projectId); + if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); }) .orWhere((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); void qb.whereNull(`${TableName.ProjectMembership}.projectId`); + if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); }) .select( selectAllTableCols(TableName.SecretVersionV2), @@ -260,6 +272,178 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { } }; + // Function to fetch latest versions by secretIds + const getLatestVersionsBySecretIds = async ( + folderId: string, + secretIds: string[], + tx?: Knex + ): Promise> => { + if (!secretIds.length) return []; + + const knexInstance = tx || db.replicaNode(); + return knexInstance(TableName.SecretVersionV2) + .where("folderId", folderId) + .whereIn(`${TableName.SecretVersionV2}.secretId`, secretIds) + .join( + knexInstance(TableName.SecretVersionV2) + .groupBy("secretId") + .max("version") + .select("secretId") + .as("latestVersion"), + (bd) => { + bd.on(`${TableName.SecretVersionV2}.secretId`, "latestVersion.secretId").andOn( + `${TableName.SecretVersionV2}.version`, + "latestVersion.max" + ); + } + ); + }; + + // Function to fetch specific versions by versionIds + const getSpecificVersionsWithLatestInfo = async ( + folderId: string, + versionIds: string[], + tx?: Knex + ): Promise> => { + if (!versionIds.length) return []; + + const knexInstance = tx || db.replicaNode(); + + // Get the specific versions + const specificVersions = await knexInstance(TableName.SecretVersionV2) + .where("folderId", folderId) + .whereIn("id", versionIds); + + // Get the secretIds from these versions + const specificSecretIds = [...new Set(specificVersions.map((v) => v.secretId).filter(Boolean))]; + + if (!specificSecretIds.length) return specificVersions; + + // Get max versions for these secretIds + const maxVersionsQuery = await knexInstance(TableName.SecretVersionV2) + .whereIn("secretId", specificSecretIds) + .groupBy("secretId") + .select("secretId") + .max("version", { as: "maxVersion" }); + + // Create a lookup map for max versions + const maxVersionMap = maxVersionsQuery.reduce( + (acc, item) => { + acc[item.secretId] = item.maxVersion; + return acc; + }, + {} as Record + ); + + // Update the version field with maxVersion when needed + return specificVersions.map((version) => { + // Replace version with maxVersion + return { + ...version, + version: maxVersionMap[version.secretId] || version.version + }; + }); + }; + + const findByIdsWithLatestVersion = async ( + folderId: string, + secretIds: string[], + versionIds?: string[], + tx?: Knex + ) => { + try { + if (!secretIds.length && (!versionIds || !versionIds.length)) return {}; + + const [latestVersions, specificVersionsWithLatest] = await Promise.all([ + secretIds.length ? getLatestVersionsBySecretIds(folderId, secretIds, tx) : [], + versionIds?.length ? getSpecificVersionsWithLatestInfo(folderId, versionIds, tx) : [] + ]); + + const allDocs = [...latestVersions, ...specificVersionsWithLatest]; + + // Convert array to record with secretId as key + return allDocs.reduce>( + (prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }), + {} + ); + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdsWithLatestVersion" }); + } + }; + + const findByIdAndPreviousVersion = async (secretVersionId: string, tx?: Knex) => { + try { + const targetSecretVersion = await (tx || db.replicaNode())(TableName.SecretVersionV2) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ id: secretVersionId }, TableName.SecretVersionV2)) + .leftJoin( + TableName.SecretVersionV2Tag, + `${TableName.SecretVersionV2}.id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretVersionV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .first(); + if (targetSecretVersion) { + const previousSecretVersion = await (tx || db.replicaNode())(TableName.SecretVersionV2) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { version: targetSecretVersion.version - 1, secretId: targetSecretVersion.secretId }, + TableName.SecretVersionV2 + ) + ) + .leftJoin( + TableName.SecretVersionV2Tag, + `${TableName.SecretVersionV2}.id`, + `${TableName.SecretVersionV2Tag}.${TableName.SecretVersionV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .select(selectAllTableCols(TableName.SecretVersionV2)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .first(); + if (!previousSecretVersion) return []; + const docs = [previousSecretVersion, targetSecretVersion]; + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretVersionsV2Schema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + } + ] + }); + + return data; + } + return []; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdAndPreviousVersion" }); + } + }; + return { ...secretVersionV2Orm, pruneExcessVersions, @@ -267,6 +451,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { bulkUpdate, findLatestVersionByFolderId, findVersionsBySecretIdWithActors, - findBySecretId + findBySecretId, + findByIdsWithLatestVersion, + findByIdAndPreviousVersion }; }; diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index e5f3acdea..96f89ab5f 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -778,6 +778,7 @@ export const createManySecretsRawFnFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, secretVersionTagV2BridgeDAL, + folderCommitService, kmsService, resourceMetadataDAL }: TCreateManySecretsRawFnFactory) => { @@ -850,6 +851,7 @@ export const createManySecretsRawFnFactory = ({ secretVersionDAL: secretVersionV2BridgeDAL, secretTagDAL, secretVersionTagDAL: secretVersionTagV2BridgeDAL, + folderCommitService, tx }) ); @@ -942,6 +944,7 @@ export const updateManySecretsRawFnFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, resourceMetadataDAL, + folderCommitService, kmsService }: TUpdateManySecretsRawFnFactory) => { const getBotKeyFn = getBotKeyFnFactory(projectBotDAL, projectDAL); @@ -1032,7 +1035,8 @@ export const updateManySecretsRawFnFactory = ({ secretDAL: secretV2BridgeDAL, secretVersionDAL: secretVersionV2BridgeDAL, secretTagDAL, - secretVersionTagDAL: secretVersionTagV2BridgeDAL + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + folderCommitService }) ); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 714df0d3f..e9c6f2d88 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -35,6 +35,7 @@ import { TSecretSyncQueueFactory } from "@app/services/secret-sync/secret-sync-q import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { ActorType } from "../auth/auth-type"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TIntegrationDALFactory } from "../integration/integration-dal"; import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal"; import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service"; @@ -112,6 +113,7 @@ type TSecretQueueFactoryDep = { orgService: Pick; projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; + folderCommitService: Pick; secretReminderRecipientsDAL: Pick< TSecretReminderRecipientsDALFactory, "delete" | "findUsersBySecretId" | "insertMany" | "transaction" @@ -178,7 +180,8 @@ export const secretQueueFactory = ({ projectKeyDAL, resourceMetadataDAL, secretReminderRecipientsDAL, - secretSyncQueue + secretSyncQueue, + folderCommitService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -366,7 +369,8 @@ export const secretQueueFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }); const updateManySecretsRawFn = updateManySecretsRawFnFactory({ @@ -382,7 +386,8 @@ export const secretQueueFactory = ({ secretVersionV2BridgeDAL, secretV2BridgeDAL, secretVersionTagV2BridgeDAL, - resourceMetadataDAL + resourceMetadataDAL, + folderCommitService }); /** @@ -1581,6 +1586,7 @@ export const secretQueueFactory = ({ projectDAL, webhookDAL, event: job.data, + auditLogService, secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString() }); }); diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 46ed7ef83..4fa7404d3 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -44,7 +44,8 @@ import { TGetSecretsRawByFolderMappingsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; -import { ActorType } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ChangeType } from "../folder-commit/folder-commit-service"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotServiceFactory } from "../project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -2521,6 +2522,36 @@ export const secretServiceFactory = ({ }); }; + const getSecretVersionsV2ByIds = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + secretId, + secretVersions, + secretPath, + envId, + projectId + }: TGetSecretVersionsDTO & { + secretVersions: string[]; + secretPath: string; + envId: string; + projectId: string; + }) => { + const secretVersionV2 = await secretV2BridgeService.getSecretVersionsByIds({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + secretId, + secretVersionNumbers: secretVersions, + secretPath, + envId, + projectId + }); + return secretVersionV2; + }; + const attachTags = async ({ secretName, tagSlugs, @@ -3279,6 +3310,53 @@ export const secretServiceFactory = ({ return secrets; }; + const getChangeVersions = async ( + change: { + secretVersion: string; + secretId?: string; + id?: string; + isUpdate?: boolean; + changeType?: string; + }, + previousVersion: string, + actorId: string, + actor: ActorType, + actorOrgId: string, + actorAuthMethod: ActorAuthMethod, + envId: string, + projectId: string, + secretPath: string + ) => { + const currentVersion = change.secretVersion; + const secretId = change.secretId ? change.secretId : change.id; + if (!secretId) { + return; + } + const versions = await getSecretVersionsV2ByIds({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + secretId, + // if it's update add also the previous secretversionid + secretVersions: + change.isUpdate || change.changeType === ChangeType.UPDATE + ? [currentVersion, previousVersion] + : [currentVersion], + secretPath, + envId, + projectId + }); + return versions?.map((v) => ({ + secretKey: v.secretKey, + secretComment: v.secretComment, + skipMultilineEncoding: v.skipMultilineEncoding, + tags: v.tags?.map((tag) => tag.slug), + metadata: v.metadata, + secretValue: v.secretValue + })); + }; + return { attachTags, detachTags, @@ -3309,6 +3387,8 @@ export const secretServiceFactory = ({ getSecretsRawByFolderMappings, getSecretAccessList, getSecretByIdRaw, - getAccessibleSecrets + getAccessibleSecrets, + getSecretVersionsV2ByIds, + getChangeVersions }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 30e3dfafa..91fc2eb6a 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -14,6 +14,7 @@ import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-fold import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { ActorType } from "../auth/auth-type"; +import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; @@ -441,6 +442,7 @@ export type TCreateManySecretsRawFnFactory = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; + folderCommitService: Pick; }; export type TCreateManySecretsRawFn = { @@ -478,6 +480,7 @@ export type TUpdateManySecretsRawFnFactory = { secretVersionV2BridgeDAL: Pick; secretVersionTagV2BridgeDAL: Pick; resourceMetadataDAL: Pick; + folderCommitService: Pick; }; export type TUpdateManySecretsRawFn = { diff --git a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx index b281e75d6..27092a843 100644 --- a/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx +++ b/backend/src/services/smtp/emails/OrganizationInvitationTemplate.tsx @@ -5,8 +5,8 @@ import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface OrganizationInvitationTemplateProps extends Omit { metadata?: string; - inviterFirstName: string; - inviterUsername: string; + inviterFirstName?: string; + inviterUsername?: string; organizationName: string; email: string; organizationId: string; @@ -38,11 +38,19 @@ export const OrganizationInvitationTemplate = ({
- {inviterFirstName} ( - - {inviterUsername} - - ) has invited you to collaborate on {organizationName}. + {inviterFirstName && inviterUsername ? ( + <> + {inviterFirstName} ( + + {inviterUsername} + + ) has invited you to collaborate on {organizationName}. + + ) : ( + <> + You have been invited to collaborate on {organizationName}. + + )}
diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index b5a29fc8c..0f623dff1 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -21,6 +21,11 @@ export const userDALFactory = (db: TDbClient) => { const findUserByUsername = async (username: string, tx?: Knex) => (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); + const findUserByEmail = async (email: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({ + isEmailVerified: true + }); + const getUsersByFilter = async ({ limit, offset, @@ -234,6 +239,7 @@ export const userDALFactory = (db: TDbClient) => { findOneUserAction, createUserAction, getUsersByFilter, - findAllMyAccounts + findAllMyAccounts, + findUserByEmail }; }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index aae32d91f..39300fec5 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -107,7 +107,8 @@ export const userServiceFactory = ({ }); await userDAL.updateById(user.id, { - isEmailVerified: true + isEmailVerified: true, + username: usersByusername.length === 1 && user.email ? user.email.toLowerCase() : undefined }); }; diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index a16158e14..6729e9a37 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -4,9 +4,12 @@ import { AxiosError } from "axios"; import picomatch from "picomatch"; import { TWebhooks } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType, WebhookTriggeredEvent } from "@app/ee/services/audit-log/audit-log-types"; import { request } from "@app/lib/config/request"; import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; @@ -163,6 +166,7 @@ export type TFnTriggerWebhookDTO = { projectEnvDAL: Pick; projectDAL: Pick; secretManagerDecryptor: (value: Buffer) => string; + auditLogService: Pick; }; // this is reusable function @@ -175,7 +179,8 @@ export const fnTriggerWebhook = async ({ projectEnvDAL, event, secretManagerDecryptor, - projectDAL + projectDAL, + auditLogService }: TFnTriggerWebhookDTO) => { const webhooks = await webhookDAL.findAllWebhooks(projectId, environment); const toBeTriggeredHooks = webhooks.filter( @@ -200,16 +205,43 @@ export const fnTriggerWebhook = async ({ }) ); + const eventPayloads: WebhookTriggeredEvent["metadata"][] = []; // filter hooks by status const successWebhooks = webhooksTriggered .filter(({ status }) => status === "fulfilled") - .map((_, i) => toBeTriggeredHooks[i].id); + .map((_, i) => { + eventPayloads.push({ + webhookId: toBeTriggeredHooks[i].id, + type: event.type, + payload: { + type: toBeTriggeredHooks[i].type!, + ...event.payload, + projectName + }, + status: "success" + } as WebhookTriggeredEvent["metadata"]); + + return toBeTriggeredHooks[i].id; + }); const failedWebhooks = webhooksTriggered .filter(({ status }) => status === "rejected") - .map((data, i) => ({ - id: toBeTriggeredHooks[i].id, - error: data.status === "rejected" ? (data.reason as AxiosError).message : "" - })); + .map((data, i) => { + eventPayloads.push({ + webhookId: toBeTriggeredHooks[i].id, + type: event.type, + payload: { + type: toBeTriggeredHooks[i].type!, + ...event.payload, + projectName + }, + status: "failed" + } as WebhookTriggeredEvent["metadata"]); + + return { + id: toBeTriggeredHooks[i].id, + error: data.status === "rejected" ? (data.reason as AxiosError).message : "" + }; + }); await webhookDAL.transaction(async (tx) => { const env = await projectEnvDAL.findOne({ projectId, slug: environment }, tx); @@ -236,5 +268,21 @@ export const fnTriggerWebhook = async ({ ); } }); + + for (const eventPayload of eventPayloads) { + // eslint-disable-next-line no-await-in-loop + await auditLogService.createAuditLog({ + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + projectId, + event: { + type: EventType.WEBHOOK_TRIGGERED, + metadata: eventPayload + } + }); + } + logger.info({ environment, secretPath, projectId }, "Secret webhook job ended"); }; diff --git a/cli/go.mod b/cli/go.mod index 229e37137..e6d55eb49 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -14,7 +14,7 @@ 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.96 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 @@ -25,6 +25,7 @@ require ( 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/pkg/errors v0.9.1 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 @@ -106,7 +107,6 @@ require ( github.com/pion/randutil v0.1.0 // indirect 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.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index d253c70d5..2e41c756b 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -292,8 +292,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 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.96 h1:huky6bQ1Y3oRdPb5MO3Ru868qZaPHUxZ7kP7FPNRn48= +github.com/infisical/go-sdk v0.5.96/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= diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 445941674..cb10050dd 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -576,7 +576,7 @@ func (tm *AgentManager) FetchUniversalAuthAccessToken() (credential infisicalSdk } tm.cachedUniversalAuthClientSecret = clientSecret - if tm.removeUniversalAuthClientSecretOnRead { + if universalAuthConfig.RemoveClientSecretOnRead { defer os.Remove(universalAuthConfig.ClientSecretPath) } @@ -718,7 +718,7 @@ func (tm *AgentManager) FetchNewAccessToken() error { } // Refreshes the existing access token -func (tm *AgentManager) RefreshAccessToken() error { +func (tm *AgentManager) RefreshAccessToken(accessToken string) error { httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { return err @@ -728,7 +728,6 @@ func (tm *AgentManager) RefreshAccessToken() error { SetRetryMaxWaitTime(20 * time.Second). SetRetryWaitTime(5 * time.Second) - accessToken := tm.GetToken() response, err := api.CallMachineIdentityRefreshAccessToken(httpClient, api.UniversalAuthRefreshRequest{AccessToken: accessToken}) if err != nil { return err @@ -752,18 +751,37 @@ func (tm *AgentManager) ManageTokenLifecycle() { accessTokenRefreshedTime = tm.accessTokenFetchedTime } - nextAccessTokenExpiresInTime := accessTokenRefreshedTime.Add(tm.accessTokenTTL - (5 * time.Second)) + // Calculate next expiry time at 2/3 of the TTL + nextAccessTokenExpiresInTime := accessTokenRefreshedTime.Add(tm.accessTokenTTL * 2 / 3) if tm.accessTokenFetchedTime.IsZero() && tm.accessTokenRefreshedTime.IsZero() { - // case: init login to get access token - log.Info().Msg("attempting to authenticate...") - err := tm.FetchNewAccessToken() - if err != nil { - log.Error().Msgf("unable to authenticate because %v. Will retry in 30 seconds", err) + // try to fetch token from sink files first + // if token is found, refresh the token right away and continue from there + isSavedTokenValid := false + token := tm.FetchTokenFromFiles() + if token != "" { + log.Info().Msg("found existing token in file, attempting to refresh...") + err := tm.RefreshAccessToken(token) + isSavedTokenValid = err == nil + if isSavedTokenValid { + log.Info().Msg("token refreshed successfully from saved file") + tm.accessTokenFetchedTime = time.Now() + } else { + log.Error().Msg("unable to refresh token from saved file") + } + } - // wait a bit before trying again - time.Sleep((30 * time.Second)) - continue + if !isSavedTokenValid { + // case: init login to get access token + log.Info().Msg("attempting to authenticate...") + err := tm.FetchNewAccessToken() + if err != nil { + log.Error().Msgf("unable to authenticate because %v. Will retry in 30 seconds", err) + + // wait a bit before trying again + time.Sleep((30 * time.Second)) + continue + } } } else if time.Now().After(accessTokenMaxTTLExpiresInTime) { // case: token has reached max ttl and we should re-authenticate entirely (cannot refresh) @@ -779,7 +797,7 @@ func (tm *AgentManager) ManageTokenLifecycle() { } else { // case: token ttl has expired, but the token is still within max ttl, so we can refresh log.Info().Msgf("attempting to refresh existing token...") - err := tm.RefreshAccessToken() + err := tm.RefreshAccessToken(tm.GetToken()) if err != nil { log.Error().Msgf("unable to refresh token because %v. Will retry in 30 seconds", err) @@ -800,15 +818,18 @@ func (tm *AgentManager) ManageTokenLifecycle() { accessTokenRefreshedTime = tm.accessTokenRefreshedTime } - nextAccessTokenExpiresInTime = accessTokenRefreshedTime.Add(tm.accessTokenTTL - (5 * time.Second)) + // Recalculate next expiry time at 2/3 of the TTL + nextAccessTokenExpiresInTime = accessTokenRefreshedTime.Add(tm.accessTokenTTL * 2 / 3) accessTokenMaxTTLExpiresInTime = tm.accessTokenFetchedTime.Add(tm.accessTokenMaxTTL - (5 * time.Second)) if nextAccessTokenExpiresInTime.After(accessTokenMaxTTLExpiresInTime) { - // case: Refreshed so close that the next refresh would occur beyond max ttl (this is because currently, token renew tries to add +access-token-ttl amount of time) - // example: access token ttl is 11 sec and max ttl is 30 sec. So it will start with 11 seconds, then 22 seconds but the next time you call refresh it would try to extend it to 33 but max ttl only allows 30, so the token will be valid until 30 before we need to reauth - time.Sleep(tm.accessTokenTTL - nextAccessTokenExpiresInTime.Sub(accessTokenMaxTTLExpiresInTime)) + // case: Refreshed so close that the next refresh would occur beyond max ttl + // Sleep until we're at 2/3 of the remaining time to max TTL + remainingTime := accessTokenMaxTTLExpiresInTime.Sub(time.Now()) + time.Sleep(remainingTime * 2 / 3) } else { - time.Sleep(tm.accessTokenTTL - (5 * time.Second)) + // Sleep until we're at 2/3 of the TTL + time.Sleep(tm.accessTokenTTL * 2 / 3) } } } @@ -830,6 +851,24 @@ func (tm *AgentManager) WriteTokenToFiles() { } } +func (tm *AgentManager) FetchTokenFromFiles() string { + for _, sinkFile := range tm.filePaths { + if sinkFile.Type == "file" { + tokenBytes, err := ioutil.ReadFile(sinkFile.Config.Path) + if err != nil { + log.Debug().Msgf("unable to read token from file '%s' because %v", sinkFile.Config.Path, err) + continue + } + + token := string(tokenBytes) + if token != "" { + return token + } + } + } + return "" +} + func (tm *AgentManager) WriteTemplateToFile(bytes *bytes.Buffer, template *Template) { if err := WriteBytesToFile(bytes, template.DestinationPath); err != nil { log.Error().Msgf("template engine: unable to write secrets to path because %s. Will try again on next cycle", err) diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index 8761b84ef..0443e7714 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -49,6 +49,11 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectSlug, err := cmd.Flags().GetString("project-slug") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse path flag") @@ -60,10 +65,10 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to get resty client with custom headers") } - if projectId == "" { + if projectId == "" && projectSlug == "" { workspaceFile, 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") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, pass in project slug with --project-slug flag, or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -100,13 +105,16 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { }) infisicalClient.Auth().SetAccessToken(infisicalToken) - projectDetails, err := api.CallGetProjectById(httpClient, projectId) - if err != nil { - util.HandleError(err, "To fetch project details") + if projectSlug == "" { + projectDetails, err := api.CallGetProjectById(httpClient, projectId) + if err != nil { + util.HandleError(err, "To fetch project details") + } + projectSlug = projectDetails.Slug } dynamicSecretRootCredentials, err := infisicalClient.DynamicSecrets().List(infisicalSdk.ListDynamicSecretsRootCredentialsOptions{ - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, SecretPath: secretsPath, EnvironmentSlug: environmentName, }) @@ -156,6 +164,11 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectSlug, err := cmd.Flags().GetString("project-slug") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + ttl, err := cmd.Flags().GetString("ttl") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -177,10 +190,10 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to get resty client with custom headers") } - if projectId == "" { + if projectId == "" && projectSlug == "" { workspaceFile, 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") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, pass in project id with --projectId flag, or pass in project slug with --project-slug flag") } projectId = workspaceFile.WorkspaceId } @@ -216,14 +229,17 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { }) infisicalClient.Auth().SetAccessToken(infisicalToken) - projectDetails, err := api.CallGetProjectById(httpClient, projectId) - if err != nil { - util.HandleError(err, "To fetch project details") + if projectSlug == "" { + projectDetails, err := api.CallGetProjectById(httpClient, projectId) + if err != nil { + util.HandleError(err, "To fetch project details") + } + projectSlug = projectDetails.Slug } dynamicSecretRootCredential, err := infisicalClient.DynamicSecrets().GetByName(infisicalSdk.GetDynamicSecretRootCredentialByNameOptions{ DynamicSecretName: dynamicSecretRootCredentialName, - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, SecretPath: secretsPath, EnvironmentSlug: environmentName, }) @@ -232,13 +248,26 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "To fetch dynamic secret root credentials details") } + // for Kubernetes dynamic secrets only + kubernetesNamespace, err := cmd.Flags().GetString("kubernetes-namespace") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + config := map[string]any{} + if kubernetesNamespace != "" { + config["namespace"] = kubernetesNamespace + } + leaseCredentials, _, leaseDetails, err := infisicalClient.DynamicSecrets().Leases().Create(infisicalSdk.CreateDynamicSecretLeaseOptions{ DynamicSecretName: dynamicSecretRootCredential.Name, - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, TTL: ttl, SecretPath: secretsPath, EnvironmentSlug: environmentName, + Config: config, }) + if err != nil { util.HandleError(err, "To lease dynamic secret") } @@ -291,6 +320,11 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectSlug, err := cmd.Flags().GetString("project-slug") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + ttl, err := cmd.Flags().GetString("ttl") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -307,10 +341,10 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to get resty client with custom headers") } - if projectId == "" { + if projectId == "" && projectSlug == "" { workspaceFile, 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") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, pass in project slug with --project-slug flag, or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -347,9 +381,12 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { }) infisicalClient.Auth().SetAccessToken(infisicalToken) - projectDetails, err := api.CallGetProjectById(httpClient, projectId) - if err != nil { - util.HandleError(err, "To fetch project details") + if projectSlug == "" { + projectDetails, err := api.CallGetProjectById(httpClient, projectId) + if err != nil { + util.HandleError(err, "To fetch project details") + } + projectSlug = projectDetails.Slug } if err != nil { @@ -357,7 +394,7 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } leaseDetails, err := infisicalClient.DynamicSecrets().Leases().RenewById(infisicalSdk.RenewDynamicSecretLeaseOptions{ - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, TTL: ttl, SecretPath: secretsPath, EnvironmentSlug: environmentName, @@ -403,6 +440,11 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectSlug, err := cmd.Flags().GetString("project-slug") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse path flag") @@ -414,10 +456,10 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to get resty client with custom headers") } - if projectId == "" { + if projectId == "" && projectSlug == "" { workspaceFile, 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") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, pass in project slug with --project-slug flag, or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -454,9 +496,12 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { }) infisicalClient.Auth().SetAccessToken(infisicalToken) - projectDetails, err := api.CallGetProjectById(httpClient, projectId) - if err != nil { - util.HandleError(err, "To fetch project details") + if projectSlug == "" { + projectDetails, err := api.CallGetProjectById(httpClient, projectId) + if err != nil { + util.HandleError(err, "To fetch project details") + } + projectSlug = projectDetails.Slug } if err != nil { @@ -464,11 +509,12 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { } leaseDetails, err := infisicalClient.DynamicSecrets().Leases().DeleteById(infisicalSdk.DeleteDynamicSecretLeaseOptions{ - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, SecretPath: secretsPath, EnvironmentSlug: environmentName, LeaseId: dynamicSecretLeaseId, }) + if err != nil { util.HandleError(err, "To revoke dynamic secret lease") } @@ -509,6 +555,11 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to parse flag") } + projectSlug, err := cmd.Flags().GetString("project-slug") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse path flag") @@ -520,10 +571,10 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { util.HandleError(err, "Unable to get resty client with custom headers") } - if projectId == "" { + if projectId == "" && projectSlug == "" { workspaceFile, 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") + util.PrintErrorMessageAndExit("Please either run infisical init to connect to a project, pass in project slug with --project-slug flag, or pass in project id with --projectId flag") } projectId = workspaceFile.WorkspaceId } @@ -559,14 +610,17 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { }) infisicalClient.Auth().SetAccessToken(infisicalToken) - projectDetails, err := api.CallGetProjectById(httpClient, projectId) - if err != nil { - util.HandleError(err, "To fetch project details") + if projectSlug == "" { + projectDetails, err := api.CallGetProjectById(httpClient, projectId) + if err != nil { + util.HandleError(err, "To fetch project details") + } + projectSlug = projectDetails.Slug } dynamicSecretLeases, err := infisicalClient.DynamicSecrets().Leases().List(infisicalSdk.ListDynamicSecretLeasesOptions{ DynamicSecretName: dynamicSecretRootCredentialName, - ProjectSlug: projectDetails.Slug, + ProjectSlug: projectSlug, SecretPath: secretsPath, EnvironmentSlug: environmentName, }) @@ -583,30 +637,39 @@ func init() { dynamicSecretLeaseCreateCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") dynamicSecretLeaseCreateCmd.Flags().String("token", "", "Create dynamic secret leases using machine identity access token") dynamicSecretLeaseCreateCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") + dynamicSecretLeaseCreateCmd.Flags().String("project-slug", "", "Manually set the project-slug to create lease in") dynamicSecretLeaseCreateCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.") dynamicSecretLeaseCreateCmd.Flags().Bool("plain", false, "Print leased credentials without formatting, one per line") + + // Kubernetes specific flags + dynamicSecretLeaseCreateCmd.Flags().String("kubernetes-namespace", "", "The namespace to create the lease in. Only used for Kubernetes dynamic secrets.") + dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseCreateCmd) dynamicSecretLeaseListCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") dynamicSecretLeaseListCmd.Flags().String("token", "", "Fetch dynamic secret leases machine identity access token") dynamicSecretLeaseListCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") + dynamicSecretLeaseListCmd.Flags().String("project-slug", "", "Manually set the project-slug to list leases from") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseListCmd) dynamicSecretLeaseRenewCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") dynamicSecretLeaseRenewCmd.Flags().String("token", "", "Renew dynamic secrets machine identity access token") dynamicSecretLeaseRenewCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") + dynamicSecretLeaseRenewCmd.Flags().String("project-slug", "", "Manually set the project-slug to renew lease in") dynamicSecretLeaseRenewCmd.Flags().String("ttl", "", "The lease lifetime TTL. If not provided the default TTL of dynamic secret will be used.") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRenewCmd) dynamicSecretLeaseRevokeCmd.Flags().StringP("path", "p", "/", "The path from where dynamic secret should be leased from") dynamicSecretLeaseRevokeCmd.Flags().String("token", "", "Delete dynamic secrets using machine identity access token") dynamicSecretLeaseRevokeCmd.Flags().String("projectId", "", "Manually set the projectId to fetch leased from when using machine identity based auth") + dynamicSecretLeaseRevokeCmd.Flags().String("project-slug", "", "Manually set the project-slug to revoke lease from") dynamicSecretLeaseCmd.AddCommand(dynamicSecretLeaseRevokeCmd) dynamicSecretCmd.AddCommand(dynamicSecretLeaseCmd) dynamicSecretCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") dynamicSecretCmd.Flags().String("projectId", "", "Manually set the projectId to fetch dynamic-secret when using machine identity based auth") + dynamicSecretCmd.Flags().String("project-slug", "", "Manually set the project-slug to fetch dynamic-secret from") dynamicSecretCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") dynamicSecretCmd.Flags().String("path", "/", "get dynamic secret within a folder path") rootCmd.AddCommand(dynamicSecretCmd) diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 51565b6fd..abc4d6949 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -7,16 +7,77 @@ 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 := util.GetCmdFlagOrEnv(cmd, "auth-method", []string{util.INFISICAL_AUTH_METHOD_NAME}) + + 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 +87,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 +107,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 +122,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 +159,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 +221,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 +278,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 +293,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/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 b0ce7564b..fd3ce1569 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -49,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, "-", " ") } @@ -154,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") @@ -310,17 +233,20 @@ 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, + util.AuthStrategy.JWT_AUTH: sdkAuthenticator.HandleJwtAuthLogin, } - credential, err := authStrategies[strategy](cmd, infisicalClient) + credential, err := authStrategies[strategy]() if err != nil { euErrorMessage := "" @@ -518,14 +444,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) { diff --git a/cli/packages/gateway/connection.go b/cli/packages/gateway/connection.go index 58a0503ff..980137374 100644 --- a/cli/packages/gateway/connection.go +++ b/cli/packages/gateway/connection.go @@ -4,11 +4,19 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" + "fmt" "io" "net" + "net/http" + "net/url" + "os" "strings" "sync" + "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog/log" @@ -18,9 +26,13 @@ func handleConnection(ctx context.Context, quicConn quic.Connection) { log.Info().Msgf("New connection from: %s", quicConn.RemoteAddr().String()) // Use WaitGroup to track all streams var wg sync.WaitGroup + + contextWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + for { // Accept the first stream, which we'll use for commands - stream, err := quicConn.AcceptStream(ctx) + stream, err := quicConn.AcceptStream(contextWithTimeout) if err != nil { log.Printf("Failed to accept QUIC stream: %v", err) break @@ -44,7 +56,12 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { // Use buffered reader for better handling of fragmented data reader := bufio.NewReader(stream) - defer stream.Close() + defer func() { + log.Info().Msgf("Closing stream %d", streamID) + if stream != nil { + stream.Close() + } + }() for { msg, err := reader.ReadBytes('\n') @@ -89,6 +106,39 @@ 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": + targetURL := "" + argParts := bytes.Split(args, []byte(" ")) + + if len(argParts) == 0 || len(argParts[0]) == 0 { + log.Warn().Msg("FORWARD-HTTP used without a target URL.") + } else { + targetURL = string(argParts[0]) + if !isValidURL(targetURL) { + log.Error().Msgf("Invalid target URL: %s", targetURL) + return + } + } + + // 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,11 +150,177 @@ 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) + } + } + + if verifyParam != "" { + tlsConfig.InsecureSkipVerify = verifyParam == "false" + log.Info().Msgf("TLS verification set to: %s", verifyParam) + } + + transport.TLSClientConfig = tlsConfig + } + + // Loop to handle multiple HTTP requests on the same stream + for { + req, err := http.ReadRequest(reader) + + if err != nil { + if errors.Is(err, io.EOF) { + log.Info().Msg("Client closed HTTP connection") + return nil + } + return fmt.Errorf("failed to read HTTP request: %v", err) + } + log.Info().Msgf("Received HTTP request: %s", req.URL.Path) + + actionHeader := HttpProxyAction(req.Header.Get(INFISICAL_HTTP_PROXY_ACTION_HEADER)) + if actionHeader != "" { + if actionHeader == HttpProxyActionInjectGatewayK8sServiceAccountToken { + token, err := os.ReadFile(KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH) + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa auth token"))) + continue // Continue to next request instead of returning + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token))) + log.Info().Msgf("Injected gateway k8s SA auth token in request to %s", targetURL) + } else if actionHeader == HttpProxyActionUseGatewayK8sServiceAccount { // will work without a target URL set + // set the ca cert to the pod's k8s service account ca cert: + caCert, err := os.ReadFile(KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH) + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa ca cert"))) + continue + } + + caCertPool := x509.NewCertPool() + if ok := caCertPool.AppendCertsFromPEM(caCert); !ok { + stream.Write([]byte(buildHttpInternalServerError("failed to parse k8s sa ca cert"))) + continue + } + + transport.TLSClientConfig = &tls.Config{ + RootCAs: caCertPool, + } + + // set authorization header to the pod's k8s service account token: + token, err := os.ReadFile(KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH) + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa auth token"))) + continue + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token))) + + // update the target URL to point to the kubernetes API server: + kubernetesServiceHost := os.Getenv(KUBERNETES_SERVICE_HOST_ENV_NAME) + kubernetesServicePort := os.Getenv(KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME) + + fullBaseUrl := fmt.Sprintf("https://%s:%s", kubernetesServiceHost, kubernetesServicePort) + targetURL = fullBaseUrl + + log.Info().Msgf("Redirected request to Kubernetes API server: %s", targetURL) + } + + req.Header.Del(INFISICAL_HTTP_PROXY_ACTION_HEADER) + } + + // Build full target URL + 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) + if err != nil { + log.Error().Msgf("Failed to create proxy request: %v", err) + stream.Write([]byte(buildHttpInternalServerError("failed to create proxy request"))) + continue // Continue to next request + } + proxyReq.Header = req.Header.Clone() + + log.Info().Msgf("Proxying %s %s to %s", req.Method, req.URL.Path, targetFullURL) + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + resp, err := client.Do(proxyReq) + if err != nil { + log.Error().Msgf("Failed to reach target: %v", err) + stream.Write([]byte(buildHttpInternalServerError(fmt.Sprintf("failed to reach target due to networking error: %s", err.Error())))) + continue // Continue to next request + } + + // 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 { + log.Error().Err(err).Msg("Failed to write response to stream") + resp.Body.Close() + return fmt.Errorf("failed to write response to stream: %w", err) + } + + resp.Body.Close() + + // Check if client wants to close connection + if req.Header.Get("Connection") == "close" { + log.Info().Msg("Client requested connection close") + 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 } +func isValidURL(str string) bool { + u, err := url.Parse(str) + return err == nil && u.Scheme != "" && u.Host != "" +} + func CopyDataFromQuicToTcp(quicStream quic.Stream, tcpConn net.Conn) { // Create a WaitGroup to wait for both copy operations var wg sync.WaitGroup diff --git a/cli/packages/gateway/constants.go b/cli/packages/gateway/constants.go new file mode 100644 index 000000000..aa260ed2e --- /dev/null +++ b/cli/packages/gateway/constants.go @@ -0,0 +1,17 @@ +package gateway + +const ( + KUBERNETES_SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST" + KUBERNETES_SERVICE_PORT_HTTPS_ENV_NAME = "KUBERNETES_SERVICE_PORT_HTTPS" + KUBERNETES_SERVICE_ACCOUNT_CA_CERT_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token" + + INFISICAL_HTTP_PROXY_ACTION_HEADER = "x-infisical-action" +) + +type HttpProxyAction string + +const ( + HttpProxyActionInjectGatewayK8sServiceAccountToken HttpProxyAction = "inject-k8s-sa-auth-token" + HttpProxyActionUseGatewayK8sServiceAccount HttpProxyAction = "use-k8s-sa" +) 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 b54bde45b..eaf7cecc1 100644 --- a/cli/packages/util/auth.go +++ b/cli/packages/util/auth.go @@ -5,7 +5,9 @@ import ( "os" "os/exec" + infisicalSdk "github.com/infisical/go-sdk" "github.com/rs/zerolog/log" + "github.com/spf13/cobra" ) type AuthStrategyType string @@ -18,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", @@ -26,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{ @@ -36,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) { @@ -84,3 +89,120 @@ func EstablishUserLoginSession() LoggedInUserDetails { 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..126e5a5d0 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -13,6 +13,8 @@ const ( VAULT_BACKEND_AUTO_MODE = "auto" VAULT_BACKEND_FILE_MODE = "file" + INFISICAL_AUTH_METHOD_NAME = "INFISICAL_AUTH_METHOD" + // Universal Auth INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET" @@ -24,7 +26,12 @@ 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" + + INFISICAL_GATEWAY_TOKEN_NAME_LEGACY = "TOKEN" // backwards compatibility with gateway helm chart, where token was the only supported auth method // 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/helper.go b/cli/packages/util/helper.go index 153a5a281..abd9768aa 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -96,6 +96,11 @@ func GetInfisicalToken(cmd *cobra.Command) (token *models.TokenDetails, err erro infisicalToken = os.Getenv(INFISICAL_TOKEN_NAME) source = fmt.Sprintf("%s environment variable", INFISICAL_TOKEN_NAME) } + + if infisicalToken == "" { // if its still empty, check for the `TOKEN` environment variable (for gateway helm) + infisicalToken = os.Getenv(INFISICAL_GATEWAY_TOKEN_NAME_LEGACY) + source = fmt.Sprintf("%s environment variable", INFISICAL_GATEWAY_TOKEN_NAME_LEGACY) + } } if infisicalToken == "" { // If it's empty, we return nothing at all. @@ -292,13 +297,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/docs/api-reference/endpoints/alicloud-auth/attach.mdx b/docs/api-reference/endpoints/alicloud-auth/attach.mdx new file mode 100644 index 000000000..1e21eb749 --- /dev/null +++ b/docs/api-reference/endpoints/alicloud-auth/attach.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach" +openapi: "POST /api/v1/auth/alicloud-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/alicloud-auth/login.mdx b/docs/api-reference/endpoints/alicloud-auth/login.mdx new file mode 100644 index 000000000..511778200 --- /dev/null +++ b/docs/api-reference/endpoints/alicloud-auth/login.mdx @@ -0,0 +1,4 @@ +--- +title: "Login" +openapi: "POST /api/v1/auth/alicloud-auth/login" +--- diff --git a/docs/api-reference/endpoints/alicloud-auth/retrieve.mdx b/docs/api-reference/endpoints/alicloud-auth/retrieve.mdx new file mode 100644 index 000000000..b63f9c746 --- /dev/null +++ b/docs/api-reference/endpoints/alicloud-auth/retrieve.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/auth/alicloud-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/alicloud-auth/revoke.mdx b/docs/api-reference/endpoints/alicloud-auth/revoke.mdx new file mode 100644 index 000000000..74349ab0a --- /dev/null +++ b/docs/api-reference/endpoints/alicloud-auth/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "DELETE /api/v1/auth/alicloud-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/alicloud-auth/update.mdx b/docs/api-reference/endpoints/alicloud-auth/update.mdx new file mode 100644 index 000000000..8295658ba --- /dev/null +++ b/docs/api-reference/endpoints/alicloud-auth/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/auth/alicloud-auth/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/available.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/available.mdx new file mode 100644 index 000000000..67390861d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-devops/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/create.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/create.mdx new file mode 100644 index 000000000..cf4d4e229 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-devops" +--- + + + Azure DevOps Connections must be created through the Infisical UI if you are using OAuth. + Check out the configuration docs for [Azure DevOps Connections](/integrations/app-connections/azure-devops) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/delete.mdx new file mode 100644 index 000000000..f6c2f9951 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-devops/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/get-by-id.mdx new file mode 100644 index 000000000..ac411a2bd --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-devops/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/get-by-name.mdx new file mode 100644 index 000000000..17b29e405 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-devops/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/list.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/list.mdx new file mode 100644 index 000000000..89cd0a641 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-devops" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-devops/update.mdx b/docs/api-reference/endpoints/app-connections/azure-devops/update.mdx new file mode 100644 index 000000000..64b3f64cc --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-devops/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-devops/{connectionId}" +--- + + + Azure DevOps Connections must be updated through the Infisical UI if you are using OAuth. + Check out the configuration docs for [Azure DevOps Connections](/integrations/app-connections/azure-devops) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/oracledb/available.mdx b/docs/api-reference/endpoints/app-connections/oracledb/available.mdx new file mode 100644 index 000000000..ffc067b3a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/oracledb/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/oracledb/create.mdx b/docs/api-reference/endpoints/app-connections/oracledb/create.mdx new file mode 100644 index 000000000..06d9170ee --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/oracledb" +--- + + + Check out the configuration docs for [OracleDB Connections](/integrations/app-connections/oracledb) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/oracledb/delete.mdx b/docs/api-reference/endpoints/app-connections/oracledb/delete.mdx new file mode 100644 index 000000000..3331d4c8f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/oracledb/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oracledb/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/oracledb/get-by-id.mdx new file mode 100644 index 000000000..c6d445101 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/oracledb/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oracledb/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/oracledb/get-by-name.mdx new file mode 100644 index 000000000..ba16d8734 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/oracledb/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/oracledb/list.mdx b/docs/api-reference/endpoints/app-connections/oracledb/list.mdx new file mode 100644 index 000000000..38964a479 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/oracledb" +--- diff --git a/docs/api-reference/endpoints/app-connections/oracledb/update.mdx b/docs/api-reference/endpoints/app-connections/oracledb/update.mdx new file mode 100644 index 000000000..41cc6e5bc --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/oracledb/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/oracledb/{connectionId}" +--- + + + Check out the configuration docs for [OracleDB Connections](/integrations/app-connections/oracledb) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx b/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx new file mode 100644 index 000000000..1a4e26709 --- /dev/null +++ b/docs/api-reference/endpoints/dynamic-secrets/kubernetes/create-lease.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Kubernetes Lease" +openapi: "POST /api/v1/dynamic-secrets/leases/kubernetes" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/create.mdx new file mode 100644 index 000000000..d3bb74fc6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/oracledb-credentials" +--- + + + Check out the configuration docs for [OracleDB Credentials Rotations](/documentation/platform/secret-rotation/oracledb-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/delete.mdx new file mode 100644 index 000000000..c916a27a5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/oracledb-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id.mdx new file mode 100644 index 000000000..980c670e7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/oracledb-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name.mdx new file mode 100644 index 000000000..0aeec7a3e --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/oracledb-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..18c03cf06 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/oracledb-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/list.mdx new file mode 100644 index 000000000..25e70df38 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/oracledb-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..40066565a --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/oracledb-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/update.mdx new file mode 100644 index 000000000..2436136d7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/oracledb-credentials/update.mdx @@ -0,0 +1,8 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/oracledb-credentials/{rotationId}" +--- + + + Check out the configuration docs for [OracleDB Credentials Rotations](/documentation/platform/secret-rotation/oracledb-credentials) to learn how to obtain the required parameters. + diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/create.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/create.mdx new file mode 100644 index 000000000..fb304bc33 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/azure-devops" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/delete.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/delete.mdx new file mode 100644 index 000000000..b57a08c66 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/azure-devops/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-id.mdx new file mode 100644 index 000000000..cd2fc2b40 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/azure-devops/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-name.mdx new file mode 100644 index 000000000..ded315b46 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/azure-devops/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/import-secrets.mdx new file mode 100644 index 000000000..2429ebcb6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/azure-devops/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/list.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/list.mdx new file mode 100644 index 000000000..d12e2bab7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/azure-devops" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/remove-secrets.mdx new file mode 100644 index 000000000..b14b23fd5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/azure-devops/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/sync-secrets.mdx new file mode 100644 index 000000000..97af04a5d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/azure-devops/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/azure-devops/update.mdx b/docs/api-reference/endpoints/secret-syncs/azure-devops/update.mdx new file mode 100644 index 000000000..0fb0e8f67 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/azure-devops/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/azure-devops/{syncId}" +--- diff --git a/docs/cli/commands/dynamic-secrets.mdx b/docs/cli/commands/dynamic-secrets.mdx index c345c3e2d..76ea96cf5 100644 --- a/docs/cli/commands/dynamic-secrets.mdx +++ b/docs/cli/commands/dynamic-secrets.mdx @@ -49,7 +49,7 @@ export INFISICAL_DISABLE_UPDATE_CHECK=true ### Flags - The project ID to fetch dynamic secrets from. This is required when using a machine identity to authenticate. + The project ID to fetch dynamic secrets from. ```bash # Example @@ -58,6 +58,16 @@ infisical dynamic-secrets --projectId= + + The project slug to fetch dynamic secrets from. + +```bash +# Example +infisical dynamic-secrets --project-slug= +``` + + + The authenticated token to fetch dynamic secrets from. This is required when using a machine identity to authenticate. @@ -119,7 +129,7 @@ infisical dynamic-secrets lease create --path="/" --env=de - The project ID of the dynamic secrets to lease from. This is required when using a machine identity to authenticate. + The project ID of the dynamic secrets to lease from. ```bash # Example @@ -128,6 +138,16 @@ infisical dynamic-secrets lease create --projectId= + + The project slug of the dynamic secrets to lease from. + +```bash +# Example +infisical dynamic-secrets lease create --project-slug= +``` + + + The authenticated token to create dynamic secret leases. This is required when using a machine identity to authenticate. @@ -148,6 +168,22 @@ infisical dynamic-secrets lease create --ttl= +### Provider-specific flags + +The following flags are specific to certain providers or integrations: + + + + The namespace to create the lease in. Only used for Kubernetes dynamic secrets. + + ```bash + # Example + infisical dynamic-secrets lease create --kubernetes-namespace= + ``` + + + + This command is used to list leases for a dynamic secret. @@ -174,7 +210,7 @@ infisical dynamic-secrets lease list --path="/" --env=dev - The project ID of the dynamic secrets to list leases from. This is required when using a machine identity to authenticate. + The project ID of the dynamic secrets to list leases from. ```bash # Example @@ -183,6 +219,16 @@ infisical dynamic-secrets lease list --projectId= + + The project slug of the dynamic secrets to list leases from. + +```bash +# Example +infisical dynamic-secrets lease list --project-slug= +``` + + + The authenticated token to list dynamic secret leases. This is required when using a machine identity to authenticate. @@ -219,7 +265,7 @@ infisical dynamic-secrets lease renew --path="/" --env=dev - The project ID of the dynamic secret's lease from. This is required when using a machine identity to authenticate. + The project ID of the dynamic secret to lease from. ```bash # Example @@ -228,6 +274,16 @@ infisical dynamic-secrets lease renew --projectId= + + The project slug of the dynamic secret to lease from. + +```bash +# Example +infisical dynamic-secrets lease renew --project-slug= +``` + + + The authenticated token to create dynamic secret leases. This is required when using a machine identity to authenticate. @@ -274,7 +330,7 @@ infisical dynamic-secrets lease delete --path="/" --env=dev - The project ID of the dynamic secret's lease from. This is required when using a machine identity to authenticate. + The project ID of the dynamic secret to delete lease from. ```bash # Example @@ -283,6 +339,16 @@ infisical dynamic-secrets lease delete --projectId= + + The project slug of the dynamic secret to delete lease from. + +```bash +# Example +infisical dynamic-secrets lease delete --project-slug= +``` + + + The authenticated token to delete dynamic secret leases. This is required when using a machine identity to authenticate. diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index fd035f1fd..a12493c58 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -26,28 +26,215 @@ Run the Infisical gateway in the foreground or manage its systemd service instal Run the Infisical gateway in the foreground. The gateway will connect to the relay service and maintain a persistent connection. ```bash - infisical gateway --token= --domain= + infisical gateway --domain= --auth-method= ``` - ### Flags + ### Authentication - - The machine identity access token to authenticate with Infisical. + The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + ```bash - # Example - infisical gateway --token= + infisical gateway --auth-method=universal-auth --client-id= --client-secret= ``` - You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the gateway command. + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + + ```bash + infisical gateway --auth-method=kubernetes --machine-identity-id= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + + + ```bash + infisical gateway --auth-method=azure --machine-identity-id= + ``` + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + + + ```bash + infisical gateway --auth-method=gcp-id-token --machine-identity-id= + ``` + + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + infisical gateway --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= + ``` + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + infisical gateway --auth-method=aws-iam --machine-identity-id= + ``` + + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + infisical gateway --auth-method=oidc-auth --machine-identity-id= --jwt= + ``` + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + + ```bash + infisical gateway --auth-method=jwt-auth --jwt= --machine-identity-id= + ``` + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + infisical gateway --token= + ``` + + + + + ### Other Flags Domain of your self-hosted Infisical instance. ```bash # Example - sudo infisical gateway install --domain=https://app.your-domain.com + infisical gateway --domain=https://app.your-domain.com ``` diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index f493ff5d2..f93e3b4b2 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -190,7 +190,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa - + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. @@ -198,7 +198,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Your machine identity ID. - + The OIDC JWT from the identity provider. @@ -212,11 +212,35 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Run the `login` command with the following flags to obtain an access token: ```bash - infisical login --method=oidc-auth --machine-identity-id= --oidc-jwt= + infisical login --method=oidc-auth --machine-identity-id= --jwt= ``` + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + + + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=jwt-auth --jwt= --machine-identity-id= + ``` + + diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index a3bfd83f0..8d0236ed5 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -120,6 +120,12 @@ The CLI is designed for a variety of secret management applications ranging from + + Starting with CLI version v0.4.0, you can now choose to log in via Infisical Cloud (US/EU) or your own self-hosted instance by simply running `infisical login` and following the on-screen instructions — no need to manually set the `INFISICAL_API_URL` environment variable. + + For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL. + + ## Custom Request Headers diff --git a/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx index 5e1cc7093..b953a80bf 100644 --- a/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx +++ b/docs/documentation/platform/access-controls/abac/managing-machine-identity-attributes.mdx @@ -1,5 +1,5 @@ --- -title: "Machine identities" +title: "Machine identities" description: "Learn how to set metadata and leverage authentication attributes for machine identities." --- @@ -25,7 +25,7 @@ Machine identities can have metadata set manually, just like users. In addition, #### Accessing Attributes From Machine Identity Login -When machine identities authenticate, they may receive additional payloads/attributes from the service provider. +When machine identities authenticate, they may receive additional payloads/attributes from the service provider. For methods like OIDC, these come as claims in the token and can be made available in your policies. @@ -50,17 +50,29 @@ For methods like OIDC, these come as claims in the token and can be made availab ``` You might map: - - - **department:** to `user.department` + + - **department:** to `user.department` - **role:** to `user.role` Once configured, these attributes become available in your policies using the following format: - + ``` {{ identity.auth.oidc.claims. }} ``` + + + + For identities authenticated using Kubernetes, the service account's namespace and name are available in their policy and can be accessed as follows: + + ``` + {{ identity.auth.kubernetes.namespace }} + {{ identity.auth.kubernetes.name }} + ``` + + + At the moment we only support OIDC claims. Payloads on other authentication methods are not yet accessible. diff --git a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx index 2f38a3930..2ee4dff92 100644 --- a/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx +++ b/docs/documentation/platform/audit-log-streams/audit-log-streams.mdx @@ -5,12 +5,12 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi Audit log streams 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. -Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. +Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing. @@ -118,7 +118,7 @@ Each log entry sent to the external logging provider will follow the same struct ### Audit Logs Structure - The unique identifier for the log entry. + The unique identifier for the log entry. @@ -168,7 +168,7 @@ Each log entry sent to the external logging provider will follow the same struct If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object. - + @@ -178,7 +178,7 @@ Each log entry sent to the external logging provider will follow the same struct The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. - `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. + `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. @@ -219,4 +219,4 @@ Each log entry sent to the external logging provider will follow the same struct The name of the project where the event occurred. The `projectName` field will only be present if the event occurred at the project level, not the organization level. - \ 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 66c4c706a..92851cd03 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -95,12 +95,28 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa ![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. + + 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 + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` 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. @@ -119,15 +135,15 @@ The Infisical AWS ElastiCache dynamic secret allows you to generate AWS ElastiCa 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.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) - 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. diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 56a10419c..a02d80a5c 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -32,7 +32,8 @@ Infisical needs an initial AWS IAM user with the required permissions to create "iam:ListUserPolicies", "iam:PutUserPolicy", "iam:AddUserToGroup", - "iam:RemoveUserFromGroup" + "iam:RemoveUserFromGroup", + "iam:TagUser" ], "Resource": ["*"] } @@ -50,110 +51,304 @@ Replace **\** with your AWS account id and **\** w ## Set up Dynamic Secrets with AWS IAM - - - Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. - - - ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) - - - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) - - - - Name by which you want the secret to be referenced - + + + Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. + + To connect your self-hosted Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the configured AWS IAM Role. - - Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) - + If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. - - Maximum time-to-live for a generated secret - + The following steps are for instances not deployed on AWS: + + + Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. + + + Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAssumeAnyRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/*" + } + ] + } + ``` + + + Obtain the AWS access key ID and secret access key for your IAM User by navigating to **IAM > Users > [Your User] > Security credentials > Access keys**. - - The managing AWS IAM User Access Key - + ![Access Key Step 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![Access Key Step 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![Access Key Step 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + 1. Set the access key as **DYNAMIC_SECRET_AWS_ACCESS_KEY_ID**. + 2. Set the secret key as **DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY**. + + + - - The managing AWS IAM User Secret Key - + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) - - [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. - + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Select **Another AWS Account** and provide the appropriate Infisical AWS Account ID: use **381492033652** for the **US region**, and **345594589636** for the **EU region**. This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. (Recommended) Enable "Require external ID" and input your **Project ID** to strengthen security and mitigate the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + 5. Assign permission as shared in prerequisite. - - The AWS data center region. - + + When configuring an IAM Role that Infisical will assume, it’s highly recommended to enable the **"Require external ID"** option and specify your **Project ID**. - - The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. - + This precaution helps protect your AWS account against the [confused deputy problem](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html), a potential security vulnerability where Infisical could be tricked into performing actions on your behalf by an unauthorized actor. - - The AWS IAM groups that should be assigned to the created users. Multiple values can be provided by separating them with commas - + Always enable "Require external ID" and use your Project ID when setting up the IAM Role. + + + + ![Copy IAM Role ARN](/images/integrations/aws/integration-aws-iam-assume-arn.png) + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png) + + Name by which you want the secret to be referenced + - - The AWS IAM managed policies that should be attached to the created users. Multiple values can be provided by separating them with commas - + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + - - The AWS IAM inline policy that should be attached to the created users. Multiple values can be provided by separating them with commas - + + Maximum time-to-live for a generated secret + - -Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png) + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value - - - After submitting the form, you will see a dynamic secret created in the dashboard. + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + Select *Assume Role* method. + - ![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. - Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + The ARN of the AWS Role to assume. + - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + - 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. + + The AWS data center region. + - ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + - - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. - + + 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 + - Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + The AWS IAM inline policy that should be attached to the created users. + Multiple values can be provided by separating them with commas + - ![Provision Lease](/images/platform/dynamic-secrets/lease-values-aws-iam.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 + + + + + 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. + 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 in step 4. + + + 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) + + + + + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. + + + Navigate to the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret to. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-iam.png) + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.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 *Access Key* method. + + + + The managing AWS IAM User Access Key + + + + The managing AWS IAM User Secret Key + + + + [IAM AWS Path](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) to scope created IAM User resource access. + + + + The AWS data center region. + + + + The IAM Policy ARN of the [AWS Permissions Boundary](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html) to attach to IAM users created in the role. + + + + 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 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 + + + + + + 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. + 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 in step 4. + + + 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. 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) ## Renew Leases + To extend the life of the generated dynamic secret lease 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/azure-entra-id.mdx b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx index 515efabeb..cedeb31c2 100644 --- a/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx +++ b/docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx @@ -123,35 +123,35 @@ Click on Add assignments. Search for the application name you created and select - After submitting the form, you will see a dynamic secrets for each user created in the dashboard. + After submitting the form, you will see a dynamic secret for each user 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-redis.png) - ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.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) - 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 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/dynamic-secret-ad-lease.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 a lease before it's set time to live. +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 its set time to live. ![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 628432bea..16cca4dfe 100644 --- a/docs/documentation/platform/dynamic-secrets/cassandra.mdx +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -80,11 +80,27 @@ The above configuration allows user creation and granting permissions. ![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. + 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 + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` 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). @@ -112,7 +128,7 @@ The above configuration allows user creation and granting permissions. ![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 in step 4. + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret in step 4. diff --git a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx index 6c5028bb2..f1c5f1128 100644 --- a/docs/documentation/platform/dynamic-secrets/elastic-search.mdx +++ b/docs/documentation/platform/dynamic-secrets/elastic-search.mdx @@ -87,13 +87,29 @@ The port that your Elasticsearch instance is running on. _(Example: 9200)_ 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. + + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-input-modal-elastic-search.png) @@ -111,15 +127,15 @@ The port that your Elasticsearch instance is running on. _(Example: 9200)_ 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.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. + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. diff --git a/docs/documentation/platform/dynamic-secrets/gcp-iam.mdx b/docs/documentation/platform/dynamic-secrets/gcp-iam.mdx new file mode 100644 index 000000000..406021fa8 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/gcp-iam.mdx @@ -0,0 +1,145 @@ +--- +title: "GCP IAM" +description: "Learn how to dynamically generate GCP service account tokens." +--- + +The Infisical GCP IAM dynamic secret allows you to generate GCP service account tokens on demand based on service account permissions. + + + GCP service account access tokens cannot be revoked. As such, revoking or regenerating a token does not invalidate the old one; it remains active until it expires. + + + + You must enable the [IAM API](https://console.cloud.google.com/apis/library/iam.googleapis.com) and [IAM Credentials API](https://console.cloud.google.com/apis/library/iamcredentials.googleapis.com) in your GCP console as a prerequisite + + + + Using the GCP integration on a self-hosted instance of Infisical requires configuring a service account on GCP and + configuring your instance to use it. + + + + ![Service Account API](/images/app-connections/gcp/service-account-credentials-api.png) + + + ![Service Account IAM Page](/images/app-connections/gcp/service-account-overview.png) + + + Create a new service account that will be used to impersonate other GCP service accounts for your app connections. + ![Create Service Account Page](/images/app-connections/gcp/create-instance-service-account.png) + + Press "DONE" after creating the service account. + + + Download the JSON key file for your service account. This will be used to authenticate your instance with GCP. + ![Service Account Credential Page](/images/app-connections/gcp/create-service-account-credential.png) + + + 1. Copy the entire contents of the downloaded JSON key file. + 2. Set it as a string value for the `INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL` environment variable. + 3. Restart your Infisical instance to apply the changes. + 4. You can now use GCP integration with service account impersonation. + + + + +## Create GCP Service Account + + + + ![Service Account Page](/images/app-connections/gcp/service-account-overview.png) + + + ![Create Service Account](/images/app-connections/gcp/create-service-account.png) + + + When you assign specific roles and permissions to this service account, any tokens generated through Infisical's dynamic secrets functionality will inherit these exact permissions. This means that applications using these dynamically generated tokens will have the same access capabilities as defined by the service account's role assignments, ensuring proper access control while maintaining the principle of least privilege. + + After configuring the appropriate roles, press "DONE". + + + To enable service account impersonation, you'll need to grant the **Service Account Token Creator** role to the Infisical instance's service account. This configuration allows Infisical to securely impersonate the new service account. + - Navigate to the IAM & Admin > Service Accounts section in your Google Cloud Console + - Select the newly created service account + - Click on the "PERMISSIONS" tab + - Click "Grant Access" to add a new principal + + If you're using Infisical Cloud US, use the following service account: `infisical-us@infisical-us.iam.gserviceaccount.com` + + If you're using Infisical Cloud EU, use the following service account: `infisical-eu@infisical-eu.iam.gserviceaccount.com` + + If you're self-hosting, follow the "Self-Hosted Instance" guide at the top of the page and then use service account you created + + ![Service Account Page](/images/app-connections/gcp/service-account-grant-access.png) + + + +## Set up Dynamic Secrets with GCP IAM + + + + 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-gcp-iam-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) + + + Maximum time-to-live for a generated secret + + + The email tied to the service account created in earlier steps. + + + + 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. + 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 from it will be shown to you. + + ![Dynamic Secret Lease](/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-lease.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 a lease before its set time to live. + +![Lease Data](/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. + +![Lease Renew](/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/dynamic-secrets/kubernetes.mdx b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx index d6d051ff7..a7d16b111 100644 --- a/docs/documentation/platform/dynamic-secrets/kubernetes.mdx +++ b/docs/documentation/platform/dynamic-secrets/kubernetes.mdx @@ -33,125 +33,6 @@ This feature is ideal for scenarios where you need to: - 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 @@ -164,6 +45,371 @@ This command works for all Kubernetes environments (managed services like GKE, E ![Dynamic Secret Modal](/images/platform/dynamic-secrets/dynamic-secret-modal-kubernetes.png) + + Before proceeding with the setup, you'll need to make two key decisions: + + 1. **Credential Type**: How you want to manage service accounts + - **Static**: Use an existing service account with predefined permissions + - **Dynamic**: Create temporary service accounts with specific role assignments + + 2. **Authentication Method**: How you want to authenticate with the cluster + - **Token (API)**: Use a service account token for direct API access + - **Gateway**: Use an Infisical Gateway deployed in your cluster + + + + Static credentials generate service account tokens for a predefined service account. This is useful when you want to: + - Generate tokens for an existing service account + - Maintain consistent permissions across token generations + - Use a service account that already has the necessary RBAC permissions + + ### 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 + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to generate tokens for the target service account. + + + 1. Create a service account: + ```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. Set up RBAC permissions: + ```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 + ``` + + 3. Create and obtain the token: + ```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 + kubectl patch serviceaccount infisical-token-requester -p '{"secrets": [{"name": "infisical-token-requester-token"}]}' -n default + kubectl get secret infisical-token-requester-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to generate + tokens for the target service account. + + + + When using Gateway authentication, the Gateway will access the Kubernetes API server + using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration. + You don't need to specify these values separately in the dynamic secret configuration. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```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-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + + + Dynamic credentials create a temporary service account, assign it to a defined role/cluster-role, and generate a service account token. This is useful when you want to: + - Create temporary service accounts with specific permissions + - Automatically clean up service accounts after token expiration + - Assign different roles to different users or applications + - Maintain strict control over service account permissions + - Support multiple namespaces with a single dynamic secret configuration + + ### Prerequisites + + - A Kubernetes cluster with a service account + - Cluster access token with permissions to create service accounts and manage RBAC + - (Optional) [Gateway](/documentation/platform/gateways/overview) for private cluster access + + ### Namespace Support + + When configuring a dynamic secret, you can specify multiple allowed namespaces as a comma-separated list. During lease creation, you can then specify which namespace to use from this allowed list. This provides flexibility while maintaining security by: + + - Allowing a single dynamic secret configuration to support multiple namespaces + - Restricting service account creation to only the specified allowed namespaces + - Enabling fine-grained control over which namespaces can be used for each lease + + For example, if you configure a dynamic secret with allowed namespaces "default,kube-system,monitoring", you can create leases that use any of these namespaces while preventing access to other namespaces in your cluster. + + ### Authentication Setup + + Choose your authentication method: + + + + This method uses a service account token to authenticate with the Kubernetes cluster. It's suitable when: + - You want to use a specific service account token that you've created + - You're working with a public cluster or have network access to the cluster's API server + - You want to explicitly control which service account is used for operations + + + With Token (API) authentication, Infisical uses the provided service account token + to make API calls to your Kubernetes cluster. This token must have the necessary + permissions to create and manage service accounts, their tokens, and RBAC resources. + + + 1. Create a service account: + ```yaml service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-requester + namespace: default + --- + 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.yaml + ``` + + 2. Set up RBAC permissions: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + 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 + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-token-requester + namespace: default + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + This method uses an Infisical Gateway deployed in your Kubernetes cluster. It's ideal when: + - You want to avoid storing static service account tokens + - You prefer to use the Gateway's pre-configured service account + - You want centralized management of cluster operations + + + With Gateway authentication, Infisical communicates with the Gateway, which then + uses its own service account to make API calls to the Kubernetes API server. + The Gateway's service account must have the necessary permissions to create and + manage service accounts, their tokens, and RBAC resources. + + + + When using Gateway authentication, the Gateway will access the Kubernetes API server + using its internal cluster URL (typically https://kubernetes.default.svc) and TLS configuration. + You don't need to specify these values separately in the dynamic secret configuration. + + + 1. Deploy the Infisical Gateway in your cluster + 2. Set up RBAC permissions for the Gateway's service account: + ```yaml rbac.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: tokenrequest + rules: + - apiGroups: [""] + resources: + - "serviceaccounts/token" + - "serviceaccounts" + verbs: + - "create" + - "get" + - "delete" + - apiGroups: ["rbac.authorization.k8s.io"] + resources: + - "rolebindings" + - "clusterrolebindings" + verbs: + - "create" + - "delete" + --- + 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-gateway + namespace: infisical + --- + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-dynamic-role-binding-sa + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: infisical-dynamic-role + subjects: + - kind: ServiceAccount + name: infisical-gateway + namespace: infisical + ``` + + ```bash + kubectl apply -f rbac.yaml + ``` + + + + + In Kubernetes RBAC, a service account can only create role bindings for resources that it has access to. + This means that if you want to create dynamic service accounts with access to certain resources, + the service account creating these bindings (either the token requester or Gateway service account) + must also have access to those same resources. For example, if you want to create dynamic service + accounts that can access secrets, the token requester service account must also have access to secrets. + + + + + + Name by which you want the secret to be referenced @@ -178,56 +424,82 @@ This command works for all Kubernetes environments (managed services like GKE, E 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) + Kubernetes API server URL (e.g., https://kubernetes.default.svc). Not required when using Gateway authentication as the Gateway will use its internal cluster URL. - Whether to enable SSL verification for the Kubernetes API server connection. + Whether to enable SSL verification for the Kubernetes API server connection. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration. - Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. + Custom CA certificate for the Kubernetes API server. Leave blank to use the system/public CA. Not required when using Gateway authentication as the Gateway will use its internal TLS configuration. + + + Choose between Token (API) or Gateway authentication. If using Gateway, the Gateway must be deployed in your Kubernetes cluster. - Token with permissions to create service account tokens + Token with permissions to create service accounts and manage RBAC (required when using Token authentication) - - Name of the service account to generate tokens for - - - Kubernetes namespace where the service account exists + + Choose between Static (predefined service account) or Dynamic (temporary service accounts with role assignments) + + + + + Name of the service account to generate tokens for + + + Kubernetes namespace where the service account exists + + + + + + Kubernetes namespace(s) where the service accounts will be created. You can specify multiple namespaces as a comma-separated list (e.g., "default,kube-system"). During lease creation, you can specify which namespace to use from this allowed list. + + + Type of role to assign (ClusterRole or Role) + + + Name of the role to assign to the temporary service account + + + + Optional list of audiences to include in the generated token - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.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) - - +## Generate and Manage Tokens + +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 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 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. diff --git a/docs/documentation/platform/dynamic-secrets/ldap.mdx b/docs/documentation/platform/dynamic-secrets/ldap.mdx index 1a3eca404..a113ec344 100644 --- a/docs/documentation/platform/dynamic-secrets/ldap.mdx +++ b/docs/documentation/platform/dynamic-secrets/ldap.mdx @@ -123,13 +123,29 @@ 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 - + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + @@ -140,15 +156,15 @@ The Infisical LDAP dynamic secret allows you to generate user credentials on dem 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.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) - 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. @@ -243,15 +259,15 @@ The Infisical LDAP dynamic secret allows you to generate user credentials on dem 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.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) - 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. diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx index 5d27d16e2..f167a0641 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx @@ -63,13 +63,29 @@ Create a project scoped API Key with the required permission in your Mongo Atlas ![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. + + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + 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. diff --git a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx index f71922473..6753b8f0e 100644 --- a/docs/documentation/platform/dynamic-secrets/mongo-db.mdx +++ b/docs/documentation/platform/dynamic-secrets/mongo-db.mdx @@ -66,12 +66,28 @@ Create a user with the required permission in your MongoDB instance. This user w 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 + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-mongodb.png) @@ -93,12 +109,12 @@ Create a user with the required permission in your MongoDB instance. This user w ![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) - 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. diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index 0a73bf129..8bb942314 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -9,7 +9,6 @@ The Infisical MS SQL dynamic secret allows you to generate Microsoft SQL server Create a user with the required permission in your SQL instance. This user will be used to create new accounts on-demand. - ## Set up Dynamic Secrets with MS SQL @@ -27,104 +26,123 @@ Create a user with the required permission in your SQL instance. This user will 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 + - - List of key/value metadata pairs - + + List of key/value metadata pairs + - - Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. - + + Choose the service you want to generate dynamic secrets for. This must be selected as **MS SQL**. + - - Database host - + + Database host + - - Database port - + + Database port + - - Username that will be used to create dynamic secrets - + + Username that will be used to create dynamic secrets + - - Password that will be used to create dynamic secrets - + + Password that will be used to create dynamic secrets + - - Name of the database for which you want to create dynamic secrets - + + 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). - + + 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). + - ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png) + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-mssql.png) ![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. + + 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 + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + - 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. - - 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) - ![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. 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. 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) ## 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/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index 6f708ebba..c354dfe39 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -69,15 +69,28 @@ 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-mysql.png) - - Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + 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). + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` @@ -97,12 +110,12 @@ Create a user with the required permission in your SQL instance. This user will ![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) - 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. diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index 02b379b98..2d6193abd 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -71,15 +71,28 @@ 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. + + 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). + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` @@ -104,7 +117,7 @@ Create a user with the required permission in your SQL instance. This user will ![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. diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index f13c9c762..1f5c79f1e 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -72,12 +72,28 @@ Create a user with the required permission in your SQL instance. This user will ![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. + + 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 + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` 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). @@ -105,7 +121,7 @@ Create a user with the required permission in your SQL instance. This user will ![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. diff --git a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx index 09c04e61b..cac5ac120 100644 --- a/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx +++ b/docs/documentation/platform/dynamic-secrets/rabbit-mq.mdx @@ -66,12 +66,28 @@ The port that the RabbitMQ management plugin is listening on. This is `15672` by -Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. @@ -93,15 +109,15 @@ Allowed template variables are 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.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. + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. diff --git a/docs/documentation/platform/dynamic-secrets/redis.mdx b/docs/documentation/platform/dynamic-secrets/redis.mdx index b3e585204..583bd1d87 100644 --- a/docs/documentation/platform/dynamic-secrets/redis.mdx +++ b/docs/documentation/platform/dynamic-secrets/redis.mdx @@ -57,12 +57,28 @@ Create a user with the required permission in your Redis instance. This user wil ![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. + + 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 + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` 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). @@ -81,15 +97,15 @@ Create a user with the required permission in your Redis instance. This user wil 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.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) - 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. diff --git a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx index 2737ab084..8009c779c 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-ase.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-ase.mdx @@ -64,13 +64,29 @@ The Infisical SAP ASE dynamic secret allows you to generate SAP ASE database cre ![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. + + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. @@ -96,7 +112,7 @@ Due to SAP ASE limitations, the attached SQL statements are not executed as a tr ![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 in step 4. + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret in step 4. diff --git a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx index 597d69803..ce016e681 100644 --- a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx +++ b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx @@ -64,12 +64,28 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c ![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. + + 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 + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` @@ -103,7 +119,7 @@ The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database c ![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 in step 4. + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret in step 4. diff --git a/docs/documentation/platform/dynamic-secrets/snowflake.mdx b/docs/documentation/platform/dynamic-secrets/snowflake.mdx index 86378bbf6..10d7c182f 100644 --- a/docs/documentation/platform/dynamic-secrets/snowflake.mdx +++ b/docs/documentation/platform/dynamic-secrets/snowflake.mdx @@ -78,12 +78,28 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ![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. + 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 - + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL @@ -111,7 +127,7 @@ Infisical's Snowflake dynamic secrets allow you to generate Snowflake user crede ![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 in + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret in step 4. 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..127e544b7 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 @@ -89,18 +89,208 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t helm repo update ``` - ### Create a Kubernetes Secret with the gateway token + ### Create a Kubernetes Secret containing gateway environment variables - Create a new Kubernetes secret containing the gateway token as the `TOKEN` key. You can optionally also set the `INFISICAL_API_URL` key to your Infisical instance URL. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. + The gateway supports all identity authentication methods through the use of environment variables. + The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=TOKEN= - ``` - - - The secret name is `infisical-gateway-environment` by default. The `TOKEN` key is required, and the `INFISICAL_API_URL` key is optional. - + #### Supported authentication methods + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + The authentication method to use. Must be `universal-auth` when using Universal Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=universal-auth --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= + ``` + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The authentication method to use. Must be `kubernetes` when using Native Kubernetes. + + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `azure` when using Native Azure. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + The authentication method to use. Must be `gcp-iam` when using GCP IAM. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= + ``` + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + The authentication method to use. Must be `aws-iam` when using Native AWS IAM. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + + The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. + + + + + Your machine identity ID. + + + The OIDC JWT from the identity provider. + + + The authentication method to use. Must be `oidc-auth` when using OIDC Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= + ``` + + + + The JWT Auth method is used to authenticate with Infisical via a JWT token. + + + + + The JWT token to use for authentication. + + + Your machine identity ID. + + + The authentication method to use. Must be `jwt-auth` when using JWT Auth. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= + ``` + + + You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. + + + + + The machine identity access token to use for authentication. + + + + + ```bash + kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= + ``` + + + + + #### Other environment variables + + + + The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. + + + ### Install the Infisical Gateway Helm Chart ```bash @@ -128,7 +318,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/alicloud-auth.mdx b/docs/documentation/platform/identities/alicloud-auth.mdx new file mode 100644 index 000000000..2f54ef71b --- /dev/null +++ b/docs/documentation/platform/identities/alicloud-auth.mdx @@ -0,0 +1,192 @@ +--- +title: Alibaba Cloud Auth +description: "Learn how to authenticate with Infisical using Alibaba Cloud user accounts." +--- + +**Alibaba Cloud Auth** is an authentication method that verifies Alibaba Cloud users through signature validation, allowing secure access to Infisical resources. + +## Diagram + +The following sequence diagram illustrates the Alibaba Cloud Auth workflow for authenticating Alibaba Cloud users with Infisical. + +```mermaid +sequenceDiagram + participant Client + participant Infisical + participant Alibaba Cloud + + Note over Client,Client: Step 1: Sign user identity request + + Note over Client,Infisical: Step 2: Login Operation + Client->>Infisical: Send signed request details to /api/v1/auth/alicloud-auth/login + + Note over Infisical,Alibaba Cloud: Step 3: Request verification + Infisical->>Alibaba Cloud: Forward signed request + Alibaba Cloud-->>Infisical: Return user details + + Note over Infisical: Step 4: Identity property validation + Infisical->>Client: Return short-lived access token + + Note over Client,Infisical: Step 5: Access Infisical API with token + Client->>Infisical: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high level, Infisical authenticates an Alibaba Cloud user by verifying its identity and checking that it meets specific requirements (e.g., its ARN is whitelisted) at the `/api/v1/auth/alicloud-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 client signs a `GetCallerIdentity` request using an Alibaba Cloud user's access key secret; this is done using an HMAC sha1 algorithm. +2. The client sends the signed request information alongside the signature to Infisical at the `/api/v1/auth/alicloud-auth/login` endpoint. +3. Infisical reconstructs the request and sends it to Alibaba Cloud for verification and obtains the identity associated with the Alibaba Cloud user. +4. Infisical checks the user's properties against set criteria such as **Allowed ARNs**. +5. If all checks pass, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + +## Prerequisite + +In order to sign requests, you must have an Alibaba Cloud user with credentials such as access key ID and secret. If you're unaware of how to create a user and obtain the needed credentials, expand the menu below. + + + + + Visit https://ram.console.aliyun.com/users to get to the Users page and click **Create User**. + + ![Users Page](/images/platform/identities/alicloud/users-page.png) + + + Fill out the username and display name with values of your choice and click **OK**. + + ![User Info](/images/platform/identities/alicloud/user-info.png) + + + After a user has been created, click on its row to see user information. + + ![User Info](/images/platform/identities/alicloud/user-row.png) + + + Click **Create AccessKey** and select the most relevant option for your use-case. Then click **Continue**. + + ![User Info](/images/platform/identities/alicloud/create-access-key.png) + + + Save the displayed credentials for later steps. + + ![User Info](/images/platform/identities/alicloud/credentials.png) + + + + +## Guide + +In the following steps, we explore how to create and use identities for your workloads and applications on Alibaba Cloud to +access the Infisical API using request signing. + +### Creating an identity + +To create an identity, head to your Organization Settings > Access Control > [Identities](https://app.infisical.com/organization/access-management?selectedTab=identities) and press **Create identity**. + +![identities organization](/images/platform/identities/identities-org.png) + +When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). + +![identities organization create](/images/platform/identities/identities-org-create.png) + +Input some details for your new identity: +- **Name (required):** A friendly name for the identity. +- **Role (required):** A role from the [**Organization Roles**](https://app.infisical.com/organization/access-management?selectedTab=roles) tab for the identity to assume. The organization role assigned will determine what organization-level resources this identity can have access to. + +Once you've created an identity, you'll be redirected to a page where you can manage the identity. + +![identities page](/images/platform/identities/identities-page.png) + +Since the identity has been configured with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) by default, you should reconfigure it to use Alibaba Cloud Auth instead. To do this, click the cog next to **Universal Auth** and then select **Delete** in the options dropdown. + +![identities press cog](/images/platform/identities/identities-press-cog.png) + +![identities page remove default auth](/images/platform/identities/identities-page-remove-default-auth.png) + +Now create a new Alibaba Cloud Auth Method. + +![identities create alicloud auth method](/images/platform/identities/alicloud/create-auth-method.png) + +Here's some information about each field: +- **Allowed ARNs:** A comma-separated list of trusted Alibaba Cloud ARNs that are allowed to authenticate with Infisical. +- **Access Token TTL (default is `2592000` equivalent to 30 days):** The lifetime for an access token in seconds. This value will be referenced at renewal time. +- **Access Token Max TTL (default is `2592000` equivalent to 30 days):** The maximum lifetime for an access token in seconds. This value will be referenced at renewal time. +- **Access Token Max Number of Uses (default is `0`):** The maximum number of times that an access token can be used; a value of `0` implies an infinite number of uses. +- **Access Token Trusted IPs:** The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + +### Adding an identity to a project + +In order to allow an identity to access project-level resources such as secrets, you must add it to the relevant projects. + +To do this, head over to the project you want to add the identity to and navigate to Project Settings > Access Control > Machine Identities and press **Add Identity**. + +![identities project](/images/platform/identities/identities-project.png) + +Select the identity you want to add to the project and the project-level role you want it to assume. The project role given to the identity will determine what project-level resources this identity can access. + +![identities project create](/images/platform/identities/identities-project-create.png) + +### Accessing the Infisical API with the identity + +To access the Infisical API as the identity, you need to construct a signed `GetCallerIdentity` request and then make a request to the `/api/v1/auth/alicloud-auth/login` endpoint passing the signed data and signature. + +Below is an example of how you can authenticate with Infisical using NodeJS. + +```ts +import crypto from "crypto"; + +// We highly recommend using environment variables instead of hardcoding these values +const ALICLOUD_ACCESS_KEY_ID = "..."; +const ALICLOUD_ACCESS_KEY_SECRET = "..."; + +const params: { [key: string]: string } = { + Action: "GetCallerIdentity", + Format: "JSON", + Version: "2015-04-01", + AccessKeyId: ALICLOUD_ACCESS_KEY_ID, + SignatureMethod: "HMAC-SHA1", + Timestamp: new Date().toISOString(), + SignatureVersion: "1.0", + SignatureNonce: crypto.randomBytes(16).toString("hex"), +}; + +const canonicalizedQueryString = Object.keys(params) + .sort() + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) + .join("&"); + +const stringToSign = `GET&%2F&${encodeURIComponent(canonicalizedQueryString)}`; + +const signature = crypto + .createHmac("sha1", `${ALICLOUD_ACCESS_KEY_SECRET}&`) + .update(stringToSign) + .digest("base64"); + +const res = await fetch( + "https://app.infisical.com/api/v1/auth/alicloud-auth/login", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + identityId: "...", // Replace with your identity ID + Signature: signature, + ...params, + }), + }, +); + +const json = await res.json(); + +console.log("Infisical Response:", JSON.stringify(json)); +``` + + + Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; the default TTL is `7200` seconds, which can be adjusted. + + If an identity access token expires, it can no longer access the Infisical API. A new access token should be obtained by performing another login operation. + diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index f27d5c7bf..ab2b5cd3a 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -173,11 +173,10 @@ access the Infisical API using the AWS Auth authentication method. console.error(err); } }; - ```` + ``` + - + The following query construction is an example of how you can authenticate with Infisical from inside a EC2 instance. The shown example uses Node.js but you can use other language you wish. @@ -243,11 +242,9 @@ access the Infisical API using the AWS Auth authentication method. } main(); - ```` + ``` - + The following query construction provides a generic example of how you can construct a signed `GetCallerIdentity` query and obtain the required payload components. The shown example uses Node.js but you can use any language you wish. @@ -274,7 +271,7 @@ access the Infisical API using the AWS Auth authentication method. const signer = new AWS.Signers.V4(request, "sts"); signer.addAuthorization(AWS.config.credentials, new Date()); - ```` + ``` #### Sample request @@ -304,6 +301,96 @@ access the Infisical API using the AWS Auth authentication method. Next, you can use the access token to access the [Infisical API](/api-reference/overview/introduction) + + + The following query construction is an example of how you can authenticate with Infisical from inside an EKS pod. + + The shown example uses Node.js Typescript but you can use any language you wish. + + ```javascript + import axios from "axios"; + import { Sha256 } from "@aws-crypto/sha256-js"; + import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; + import { HttpRequest } from "@aws-sdk/protocol-http"; + import { SignatureV4 } from "@aws-sdk/signature-v4"; + + const main = async () => { + try { + const tokenRes = await axios.put("http://169.254.169.254/latest/api/token", undefined, { + headers: { + "X-aws-ec2-metadata-token-ttl-seconds": "21600" + } + }); + + const { + data: { region } + } = await axios.get<{ region: string }>("http://169.254.169.254/latest/dynamic/instance-identity/document", { + headers: { + "X-aws-ec2-metadata-token": tokenRes.data, + Accept: "application/json" + } + }); + + const credentials = await fromNodeProviderChain()(); + + if (!credentials.accessKeyId || !credentials.secretAccessKey) { + throw new Error("Credentials not found"); + } + + const iamRequestURL = `https://sts.${region}.amazonaws.com/`; + const iamRequestBody = "Action=GetCallerIdentity&Version=2011-06-15"; + const iamRequestHeaders = { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + Host: `sts.${region}.amazonaws.com` + }; + + const request = new HttpRequest({ + protocol: "https:", + hostname: `sts.${region}.amazonaws.com`, + path: "/", + method: "POST", + headers: { + ...iamRequestHeaders, + "Content-Length": String(Buffer.byteLength(iamRequestBody)) + }, + body: iamRequestBody + }); + + const signer = new SignatureV4({ + credentials, + region, + service: "sts", + sha256: Sha256 + }); + + const signedRequest = await signer.sign(request); + + const headers: Record = {}; + Object.entries(signedRequest.headers).forEach(([key, value]) => { + if (typeof value === "string") headers[key] = value; + }); + + const iamRequest = { + iamHttpRequestMethod: "POST", + iamRequestUrl: iamRequestURL, + iamRequestBody: iamRequestBody, + iamRequestHeaders: headers + }; + + const { + data: { accessToken } + } = await axios.post<{ accessToken: string }>("https://app.infisical.com/api/v1/auth/aws-auth/login", { + ...iamRequest, + identityId: "" + }); + + console.log(`Infisical Access Token: ${accessToken}`); + } catch (e) { + console.error("Failed to do AWS auth", e); + } + }; + ``` + 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/azure.mdx b/docs/documentation/platform/identities/oidc-auth/azure.mdx new file mode 100644 index 000000000..a9f244794 --- /dev/null +++ b/docs/documentation/platform/identities/oidc-auth/azure.mdx @@ -0,0 +1,157 @@ +--- +title: Azure +description: "Learn how to authenticate Azure pipelines 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 Azure pipelines with Infisical. + +```mermaid +sequenceDiagram + participant Client as Azure Pipeline + participant Idp as Identity Provider + participant Infis as Infisical + + Client->>Idp: Step 1: Request identity token + Idp-->>Client: Return JWT with verifiable claims + + Note over Client,Infis: Step 2: Login Operation + Client->>Infis: Send signed JWT to /api/v1/auth/oidc-auth/login + + Note over Infis,Idp: Step 3: Query verification + Infis->>Idp: Request JWT public key using OIDC Discovery + Idp-->>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 client by verifying the JWT and checking that it meets specific requirements (e.g. it is issued by a trusted identity provider) 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 Azure pipeline requests an identity token from Azure's identity provider. +2. The fetched identity token is sent to Infisical at the `/api/v1/auth/oidc-auth/login` endpoint. +3. Infisical fetches the public key that was used to sign the identity token from Azure's identity provider using OIDC Discovery. +4. Infisical validates the JWT using the public key provided by the identity provider and checks that the subject, audience, and claims of the token matches with the set criteria. +5. If all is well, Infisical returns a short-lived access token that the Azure pipeline can use to make authenticated requests to the Infisical API. + + + Infisical needs network-level access to Azure's identity provider endpoints. + + +## Guide + +In the following steps, we explore how to create and use identities to access the Infisical API using the OIDC Auth authentication method. + + + + 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 identity provider. This is used to fetch the public keys needed to verify the JWT. For Azure, set this to `https://login.microsoftonline.com/{tenant-id}/v2.0` (replace `{tenant-id}` with your Azure AD tenant ID).
+ -
**Issuer**: The value of the `iss` claim that the token must match. For Azure, this should be `https://login.microsoftonline.com/{tenant-id}/v2.0`.
+ - **Subject**: This must match the `sub` claim in the JWT. + - **Audiences**: Values that must match the `aud` claim. + - **Claims**: Additional claims that must be present. Refer to [Azure DevOps docs](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#workload-identity-federation) for available claims. + - **Access Token TTL**: Lifetime of the issued token (in seconds), e.g., `2592000` (30 days) + - **Access Token Max TTL**: Maximum allowed lifetime of the token + - **Access Token Max Number of Uses**: Max times the token can be used (`0` = unlimited) + - **Access Token Trusted IPs**: List of allowed IP ranges (defaults to `0.0.0.0/0`) + + If you are unsure about what to configure for the subject, audience, and claims fields, you can inspect the JWT token from your Azure DevOps pipeline by adding a debug step that outputs the token claims. + The `subject`, `audiences`, and `claims` fields support glob pattern matching; however, we highly recommend using hardcoded values whenever possible. +
+ + 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) + + + In Azure DevOps, to authenticate with Infisical using OIDC, you must configure a service connection that enables workload identity federation. + + Once set up, the OIDC token can be fetched automatically within the pipeline job context. Here's an example: + + ```yaml + trigger: + - main + + pool: + vmImage: ubuntu-latest + + steps: + - task: AzureCLI@2 + displayName: 'Retrieve secrets from Infisical using OIDC' + inputs: + azureSubscription: 'your-azure-service-connection-name' + scriptType: 'bash' + scriptLocation: 'inlineScript' + addSpnToEnvironment: true + inlineScript: | + # Get OIDC access token + OIDC_TOKEN=$(az account get-access-token --resource "api://AzureADTokenExchange" --query accessToken -o tsv) + + [ -z "$OIDC_TOKEN" ] && { echo "Failed to get access token"; exit 1; } + + # Exchange for Infisical access token + ACCESS_TOKEN=$(curl -s -X POST "/api/v1/auth/oidc-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"identityId\":\"{your-identity-id}\",\"jwt\":\"$OIDC_TOKEN\"}" \ + | jq -r '.accessToken') + + # Fetch secrets + curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \ + "/api/v3/secrets/raw?environment={your-environment-slug}&workspaceSlug={your-workspace-slug}" + ``` + + Make sure the service connection is properly configured for workload identity federation and linked to your Azure AD app registration with appropriate claims. + + + 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. + + + +
diff --git a/docs/documentation/platform/pit-recovery.mdx b/docs/documentation/platform/pit-recovery.mdx index 448faddbb..24d0071a7 100644 --- a/docs/documentation/platform/pit-recovery.mdx +++ b/docs/documentation/platform/pit-recovery.mdx @@ -4,38 +4,130 @@ description: "Learn how to rollback secrets and configurations to any snapshot w --- - Point-in-Time Recovery is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical, - then you should contact sales@infisical.com to purchase an enterprise license to use it. + Point-in-Time Recovery is a paid feature. If you're using Infisical Cloud, + then it is available under the **Pro Tier**. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license + to use it. Infisical's point-in-time recovery functionality allows secrets to be rolled back to any point in time for any given [folder](./folder) or [environment](/documentation/platform/project#project-environments). -Every time a secret is updated, a new snapshot is taken – capturing the state of the folder and environment at that point of time. -## Snapshots + + + ## Understanding Commits -Similar to Git, a commit (also known as snapshot) in Infisical is the state of your project's secrets at a specific point in time scoped to -an environment and [folder](./folder) within it. + Similar to Git, a commit in Infisical represents a snapshot of changes made to your project's resources at a specific point in time. Each commit is scoped to an environment and [folder](./folder) within it. Unlike the legacy snapshot system, the new commits interface provides granular tracking of individual changes, allowing you to see exactly what was modified, added, or removed in each commit. -To view a list of snapshots for the current folder, press the **Commits** button. + ### Accessing Commits -![PIT commits](../../images/platform/pit-recovery/pit-recovery-commits.png) + From your secrets management interface, you can access the commits functionality by clicking the **Commits Button**. This button is located in the top-right area of your secrets view and shows the number of commits for the current folder (e.g., "4 Commits"). -This opens up a sidebar from which you can select to view a particular snapshot: + ![Commits Button](../../images/platform/pit-recovery/pit-recovery-revamp/pit-commits-button.png) -![PIT snapshots](../../images/platform/pit-recovery/pit-recovery-commits-drawer.png) + ### Commits List View -## Rolling back + The commits page displays a comprehensive chronological history of all changes made to your environment and folders: -After pressing on a snapshot from the sidebar, you can view it and roll back the state -of the folder to that point in time by pressing the **Rollback** button. + ![Commits List View](../../images/platform/pit-recovery/pit-recovery-revamp/pit-commits-history.png) -![PIT snapshot](../../images/platform/pit-recovery/pit-recovery-rollback.png) + - **Chronological Sorting**: Commits are grouped by date + - **Commit Information**: Each commit shows: + - Commit message + - Author information + - Relative timestamp + - Unique commit hash identifier + - **Search Functionality**: Use the search bar to quickly find specific commits + - **Sorting Options**: Sort commits by various criteria using the sort controls -Rolling back secrets to a past snapshot creates a creates a snapshot at the top of the stack and updates secret versions. + ### Detailed Commit Inspection - -Rollbacks are localized to not affect other folders within the same environment. This means each [folder](./folder) maintains its own independent history of changes, offering precise and isolated control over rollback actions. -Put differently, every [folder](./folder) possesses a distinct and separate timeline, providing granular control when managing your secrets. - \ No newline at end of file + Clicking on any commit from the list opens a detailed view showing the list of changes made in that commit. + + ![Detailed Commit Inspection](../../images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes.png) + + #### Change Categories + + The commit changes details can be grouped into the following categories: + + **Folder Changes** + - Shows folder additions, modifications, or deletions + - Displays the folder properties changes in JSON format, including: + - Folder name + - Folder description + + **Secret Changes** + - Lists all secrets that were added, updated, or removed + - Shows the complete secret configuration including: + - Secret key and value + - Comments, tags and metadata + - Encoding settings (e.g., skipMultilineEncoding) + - Values are displayed with appropriate masking for security + + **Visual Indicators** + - Green "+" indicators show additions + - Red "-" indicators show deletions + - Modified content shows both old and new states + + ### Restoration Options + + Each commit provides two distinct restoration methods accessible via the **Restore Options** dropdown: + + ![Restore Options](../../images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes-options.png) + + #### Revert changes + This option provides surgical precision for undoing specific modifications: + + - **Granular Control**: Reverts only the specific changes introduced in that individual commit + - **Selective Restoration**: Preserves all other changes made after the commit + - **Targeted Undo**: Perfect for reversing a specific problematic change without affecting other work + - **Minimal Impact**: Only affects the resources that were modified in that particular commit + - **Use Case**: Ideal when you want to undo a specific change while keeping all other modifications intact + + #### Roll back to this commit + This option performs a complete restoration to the selected point in time: + + ![Rollback](../../images/platform/pit-recovery/pit-recovery-revamp/pit-commit-restore.png) + + - **Complete State Restoration**: Returns the entire folder to its exact state at the time of this commit + - **Restore All Child Folders**: If enabled, it'll also restore all nested folders to their exact state at the time of this commit. + - **Destructive Operation**: Discards ALL changes made after the selected commit + - **New Commit Creation**: Creates a new commit representing this rollback operation + - **Use Case**: Ideal when you want to completely undo a series of changes and return to a known good state + + **Warning**: This operation will undo all modifications made after the selected commit, which may include multiple secrets and configuration changes. + + + + + The snapshots interface is deprecated and will be removed in a future version. Please use the new Commits interface for more granular point-in-time recovery operations. + + + ## Snapshots + + Similar to Git, a commit (also known as snapshot) in Infisical is the state of your project's secrets at a specific point in time scoped to + an environment and [folder](./folder) within it. + + To view a list of snapshots for the current folder, press the **Commits** button. + + ![PIT commits](../../images/platform/pit-recovery/pit-recovery-commits.png) + + This opens up a sidebar from which you can select to view a particular snapshot: + + ![PIT snapshots](../../images/platform/pit-recovery/pit-recovery-commits-drawer.png) + + ## Rolling back + + After pressing on a snapshot from the sidebar, you can view it and roll back the state + of the folder to that point in time by pressing the **Rollback** button. + + ![PIT snapshot](../../images/platform/pit-recovery/pit-recovery-rollback.png) + + Rolling back secrets to a past snapshot creates a snapshot at the top of the stack and updates secret versions. + + + Rollbacks are localized to not affect other folders within the same environment. This means each [folder](./folder) maintains its own independent history of changes, offering precise and isolated control over rollback actions. + Put differently, every [folder](./folder) possesses a distinct and separate timeline, providing granular control when managing your secrets. + + + + diff --git a/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx b/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx new file mode 100644 index 000000000..fb0887d33 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/oracledb-credentials.mdx @@ -0,0 +1,167 @@ +--- +title: "OracleDB Credentials Rotation" +description: "Learn how to automatically rotate Oracle Database credentials." +--- + + + When working with SQL in Oracle databases, any values not surrounded by "quotes" will become UPPERCASE. Keep this in mind when creating users. + + +## Prerequisites + +1. Create a [OracleDB Connection](/integrations/app-connections/oracledb) 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 necessary privileges + GRANT ALL PRIVILEGES TO INFISICAL_USER_1; + GRANT ALL PRIVILEGES TO INFISICAL_USER_2; + ``` + + + Username must either be ALL UPPERCASE or not be surrounded by "quotes". Values not surrounded by quotes get automatically transformed to uppercase by Oracle Database. + + + + To learn more about the Oracle Database permission system, please visit their [documentation](https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-privilege-and-role-authorization.html). + + + +## Create an Oracle Database 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 **OracleDB Credentials** option. + ![Select OracleDB Credentials](/images/secret-rotations-v2/oracledb-credentials/select-oracledb-credentials-option.png) + + 3. Select the **OracleDB Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-configuration.png) + + - **OracleDB 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/oracledb-credentials/oracledb-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. + + + If your Oracle usernames were created without "quotes", Oracle sees them as UPPERCASE. Please use UPPERCASE for those names in the fields above. + + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/oracledb-credentials/oracledb-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/oracledb-credentials/oracledb-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/oracledb-credentials/oracledb-credentials-confirm.png) + + 8. Your **OracleDB Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-created.png) + + + To create a OracleDB Credentials Rotation, make an API request to the [Create OracleDB Credentials Rotation](/api-reference/endpoints/secret-rotations/oracledb-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/oracledb-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-oracledb-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": "ORACLEDB_USERNAME", + "password": "ORACLEDB_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-oracledb-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "ORACLEDB_USERNAME", + "password": "ORACLEDB_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": "oracledb", + "name": "my-oracledb-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": "oracledb-credentials", + "parameters": { + "username1": "INFISICAL_USER_1", + "username2": "INFISICAL_USER_2" + } + } + } + ``` + + diff --git a/docs/documentation/platform/sso/auth0-saml.mdx b/docs/documentation/platform/sso/auth0-saml.mdx index 22ef00c89..41706cc6b 100644 --- a/docs/documentation/platform/sso/auth0-saml.mdx +++ b/docs/documentation/platform/sso/auth0-saml.mdx @@ -62,6 +62,10 @@ description: "Learn how to configure Auth0 SAML for Infisical SSO." ``` Click **Save**. + + + Make sure the `firstName` claim is mapped to a valid field of your Auth0 users. If your users don't have a `"given_name"` field available, you can replace it with `"name"` or another field that exists in your user profile on the left side of the mapping. +
Enabling SAML SSO allows members in your organization to log into Infisical via Auth0. diff --git a/docs/documentation/platform/webhooks.mdx b/docs/documentation/platform/webhooks.mdx index 92d3ff8b8..e6e71b9b0 100644 --- a/docs/documentation/platform/webhooks.mdx +++ b/docs/documentation/platform/webhooks.mdx @@ -27,7 +27,7 @@ If the signature in the header matches the signature that you generated, then yo ```json { - "event": "secret.modified", + "event": "secrets.modified", "project": { "workspaceId": "the workspace id", "environment": "project environment", 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/azure/devops/devops-connection.png b/docs/images/app-connections/azure/devops/devops-connection.png new file mode 100644 index 000000000..b52bec7ab Binary files /dev/null and b/docs/images/app-connections/azure/devops/devops-connection.png differ diff --git a/docs/images/app-connections/azure/devops/fill-in-connection-details-oauth.png b/docs/images/app-connections/azure/devops/fill-in-connection-details-oauth.png new file mode 100644 index 000000000..a125139a1 Binary files /dev/null and b/docs/images/app-connections/azure/devops/fill-in-connection-details-oauth.png differ diff --git a/docs/images/app-connections/azure/devops/fill-in-connection-details-token.png b/docs/images/app-connections/azure/devops/fill-in-connection-details-token.png new file mode 100644 index 000000000..b78ca39fc Binary files /dev/null and b/docs/images/app-connections/azure/devops/fill-in-connection-details-token.png differ diff --git a/docs/images/app-connections/azure/devops/select-connection.png b/docs/images/app-connections/azure/devops/select-connection.png new file mode 100644 index 000000000..10867ea45 Binary files /dev/null and b/docs/images/app-connections/azure/devops/select-connection.png differ diff --git a/docs/images/app-connections/oracledb/create-username-and-password-method.png b/docs/images/app-connections/oracledb/create-username-and-password-method.png new file mode 100644 index 000000000..305710d6f Binary files /dev/null and b/docs/images/app-connections/oracledb/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/oracledb/select-oracledb-connection.png b/docs/images/app-connections/oracledb/select-oracledb-connection.png new file mode 100644 index 000000000..a9f9528ce Binary files /dev/null and b/docs/images/app-connections/oracledb/select-oracledb-connection.png differ diff --git a/docs/images/app-connections/oracledb/username-and-password-connection.png b/docs/images/app-connections/oracledb/username-and-password-connection.png new file mode 100644 index 000000000..911ec36b1 Binary files /dev/null and b/docs/images/app-connections/oracledb/username-and-password-connection.png differ diff --git a/docs/images/integrations/azure-devops/app-api-permissions.png b/docs/images/integrations/azure-devops/app-api-permissions.png new file mode 100644 index 000000000..717bb8ddb Binary files /dev/null and b/docs/images/integrations/azure-devops/app-api-permissions.png differ diff --git a/docs/images/platform/access-controls/abac-policy-k8s-format.png b/docs/images/platform/access-controls/abac-policy-k8s-format.png new file mode 100644 index 000000000..0aff7830a Binary files /dev/null and b/docs/images/platform/access-controls/abac-policy-k8s-format.png differ diff --git a/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png index 537f20e73..d60f7cd39 100644 Binary files a/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png and b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-lease.png b/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-lease.png new file mode 100644 index 000000000..6dfc98229 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-lease.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-modal.png new file mode 100644 index 000000000..e3fdfae7c Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-gcp-iam-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png deleted file mode 100644 index db7f8be35..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png b/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png index 4a816614a..769da8608 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-generate.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png deleted file mode 100644 index a7842b298..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png index e6da94dcd..63d19aa86 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png index 3d2e32e8a..aaacc9c35 100644 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png new file mode 100644 index 000000000..439208d83 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-access-key.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.png new file mode 100644 index 000000000..e3be4b5f1 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam-assume-role.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 deleted file mode 100644 index 0ba6aa172..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-aws-iam.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png new file mode 100644 index 000000000..dffbedad3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-1.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.png new file mode 100644 index 000000000..cc5e56001 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes-2.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 deleted file mode 100644 index 011dfadc7..000000000 Binary files a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-kubernetes.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/lease-data.png b/docs/images/platform/dynamic-secrets/lease-data.png index 9562da1b5..646404e85 100644 Binary files a/docs/images/platform/dynamic-secrets/lease-data.png and b/docs/images/platform/dynamic-secrets/lease-data.png differ diff --git a/docs/images/platform/dynamic-secrets/provision-lease.png b/docs/images/platform/dynamic-secrets/provision-lease.png index 96b0505b9..f8652dc05 100644 Binary files a/docs/images/platform/dynamic-secrets/provision-lease.png and b/docs/images/platform/dynamic-secrets/provision-lease.png differ diff --git a/docs/images/platform/identities/alicloud/create-access-key.png b/docs/images/platform/identities/alicloud/create-access-key.png new file mode 100644 index 000000000..1297b9b31 Binary files /dev/null and b/docs/images/platform/identities/alicloud/create-access-key.png differ diff --git a/docs/images/platform/identities/alicloud/create-auth-method.png b/docs/images/platform/identities/alicloud/create-auth-method.png new file mode 100644 index 000000000..ec6872f56 Binary files /dev/null and b/docs/images/platform/identities/alicloud/create-auth-method.png differ diff --git a/docs/images/platform/identities/alicloud/credentials.png b/docs/images/platform/identities/alicloud/credentials.png new file mode 100644 index 000000000..0b0f8e0aa Binary files /dev/null and b/docs/images/platform/identities/alicloud/credentials.png differ diff --git a/docs/images/platform/identities/alicloud/user-info.png b/docs/images/platform/identities/alicloud/user-info.png new file mode 100644 index 000000000..4bb18dc7b Binary files /dev/null and b/docs/images/platform/identities/alicloud/user-info.png differ diff --git a/docs/images/platform/identities/alicloud/user-row.png b/docs/images/platform/identities/alicloud/user-row.png new file mode 100644 index 000000000..a16447522 Binary files /dev/null and b/docs/images/platform/identities/alicloud/user-row.png differ diff --git a/docs/images/platform/identities/alicloud/users-page.png b/docs/images/platform/identities/alicloud/users-page.png new file mode 100644 index 000000000..80d2771c3 Binary files /dev/null and b/docs/images/platform/identities/alicloud/users-page.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/pit-recovery/pit-recovery-revamp/pit-commit-changes-options.png b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes-options.png new file mode 100644 index 000000000..60d9dff01 Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes-options.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes.png b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes.png new file mode 100644 index 000000000..56357e9ef Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-changes.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-restore.png b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-restore.png new file mode 100644 index 000000000..3ed22c63b Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commit-restore.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-button.png b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-button.png new file mode 100644 index 000000000..273760ca5 Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-button.png differ diff --git a/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-history.png b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-history.png new file mode 100644 index 000000000..3832209f4 Binary files /dev/null and b/docs/images/platform/pit-recovery/pit-recovery-revamp/pit-commits-history.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-configuration.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-configuration.png new file mode 100644 index 000000000..4ea91f1e8 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-confirm.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-confirm.png new file mode 100644 index 000000000..f0960c893 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-created.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-created.png new file mode 100644 index 000000000..3e07971d2 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-details.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-details.png new file mode 100644 index 000000000..9e48621f4 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-parameters.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-parameters.png new file mode 100644 index 000000000..5dcc68c32 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-secrets-mapping.png new file mode 100644 index 000000000..02b284491 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/oracledb-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/oracledb-credentials/select-oracledb-credentials-option.png b/docs/images/secret-rotations-v2/oracledb-credentials/select-oracledb-credentials-option.png new file mode 100644 index 000000000..a49ab7be6 Binary files /dev/null and b/docs/images/secret-rotations-v2/oracledb-credentials/select-oracledb-credentials-option.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-destination.png b/docs/images/secret-syncs/azure-devops/devops-destination.png new file mode 100644 index 000000000..16f506102 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-destination.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-details.png b/docs/images/secret-syncs/azure-devops/devops-details.png new file mode 100644 index 000000000..f985e6f77 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-details.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-options.png b/docs/images/secret-syncs/azure-devops/devops-options.png new file mode 100644 index 000000000..5571d7412 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-options.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-review.png b/docs/images/secret-syncs/azure-devops/devops-review.png new file mode 100644 index 000000000..104c8d468 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-review.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-source.png b/docs/images/secret-syncs/azure-devops/devops-source.png new file mode 100644 index 000000000..fa1779e87 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-source.png differ diff --git a/docs/images/secret-syncs/azure-devops/devops-synced.png b/docs/images/secret-syncs/azure-devops/devops-synced.png new file mode 100644 index 000000000..e47282364 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/devops-synced.png differ diff --git a/docs/images/secret-syncs/azure-devops/select-azure-devops-option.png b/docs/images/secret-syncs/azure-devops/select-azure-devops-option.png new file mode 100644 index 000000000..f11192b36 Binary files /dev/null and b/docs/images/secret-syncs/azure-devops/select-azure-devops-option.png differ diff --git a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png index c50b6232a..1841b4b6d 100644 Binary files a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png and b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-destination.png differ diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx new file mode 100644 index 000000000..8fcc25427 --- /dev/null +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -0,0 +1,137 @@ +--- +title: "Azure DevOps Connection" +description: "Learn how to configure an Azure DevOps Connection for Infisical." +--- + +Infisical currently supports two methods for connecting to Azure DevOps, which are OAuth and Azure DevOps Personal Access Token. + + + Using the Azure DevOps OAuth connection on a self-hosted instance of Infisical requires configuring an application in Azure + and registering your instance with it. + + **Prerequisites:** + + - Set up Azure. + + + + Navigate to Azure Active Directory > App registrations to create a new application. + + + Azure Active Directory is now Microsoft Entra ID. + + ![Azure devops](/images/integrations/azure-app-configuration/config-aad.png) + ![Azure devops](/images/integrations/azure-app-configuration/config-new-app.png) + + Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/organization/app-connections/azure/oauth/callback`. + + The domain you defined in the Redirect URI should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + ![Azure devops](/images/app-connections/azure/register-callback.png) + + + + For the Azure Connection to work with DevOps Pipelines, you need to assign the following permission to the application. + + #### Azure DevOps permissions + + Set the API permissions of the Azure application to include the following permissions: + - Azure DevOps + - `user_impersonation` + - `vso.project_write` + - `vso.variablegroups_manage` + - `vso.variablegroups_write` + + ![Azure devops](/images/integrations/azure-devops/app-api-permissions.png) + + + + + Obtain the **Application (Client) ID** and **Directory (Tenant) ID** (this will be used later in the Infisical connection) in Overview and generate a **Client Secret** in Certificate & secrets for your Azure application. + + ![Azure devops](../../images/app-connections/azure/client-secrets/config-credentials-1.png) + ![Azure devops](../../images/integrations/azure-app-configuration/config-credentials-2.png) + ![Azure devops](../../images/integrations/azure-app-configuration/config-credentials-3.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. + + - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + + Once added, restart your Infisical instance and use the Azure Client Secrets connection. + + + + + + + #### Create a new Azure DevOps personal access token (PAT) + When using the Azure DevOps Access Token connection you'll need to create a new personal access token (PAT) in order to authenticate Infisical with Azure DevOps. + + + ![integrations](../../images/integrations/azure-devops/overview-page.png) + + + Make sure the newly created token has Read/Write access to the Release scope. + ![integrations](../../images/integrations/azure-devops/create-new-token.png) + + + Please make sure that the token has access to the following scopes: Variable Groups _(read, create, & manage)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_ + + + + Copy the newly created token as this will be used to authenticate Infisical with Azure DevOps. + ![integrations](../../images/integrations/azure-devops/new-token-created.png) + + + + +## Setup Azure 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 **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/devops/select-connection.png) + + + + + + + + Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous [step](#azure-oauth-on-a-self-hosted-instance). Also fill in the organization name of the Azure DevOps organization you want to connect to. + ![Fill in Connection Details](/images/app-connections/azure/devops/fill-in-connection-details-oauth.png) + + + You can find the **Organization Name** on https://dev.azure.com/ + + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets + Authorization](/images/app-connections/azure/grant-access.png) + + + + + + + Fill in the **Access Token** field with the Access Token you obtained in the previous step. And the organization name of the Azure DevOps organization you want to connect to. + ![Fill in Connection Details](/images/app-connections/azure/devops/fill-in-connection-details-token.png) + + + You can find the **Organization Name** on https://dev.azure.com/ + + + + + + + + Your **Azure DevOps Connection** is now available for use. ![Azure DevOps](/images/app-connections/azure/devops/devops-connection.png) + + diff --git a/docs/integrations/app-connections/oracledb.mdx b/docs/integrations/app-connections/oracledb.mdx new file mode 100644 index 000000000..8cab6371b --- /dev/null +++ b/docs/integrations/app-connections/oracledb.mdx @@ -0,0 +1,139 @@ +--- +title: "OracleDB Connection" +description: "Learn how to configure a Oracle Database Connection for Infisical." +--- + + + OracleDB App Connection 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. + + +Infisical supports connecting to OracleDB using a database user. + +## Configure an Oracle Database User for Infisical + + + + Infisical recommends creating a designated user in your Oracle Database for your connection. + ```SQL + -- create user + CREATE USER infisical IDENTIFIED BY "my-password"; + + -- grant create session privileges + GRANT CREATE SESSION TO infisical; + ``` + + Username must either be ALL UPPERCASE or not be surrounded by "quotes". Values not surrounded by quotes get automatically transformed to uppercase by Oracle Database. + + + + Depending on how you intend to use your OracleDB connection, you'll need to grant one or more of the following permissions. + + To learn more about the Oracle Database permission system, please visit their [documentation](https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/configuring-privilege-and-role-authorization.html). + + + + For Secret Rotations, your Infisical user will require the ability to alter other users' passwords: + ```SQL + -- enable permissions to alter login credentials + GRANT ALTER USER TO infisical; + ``` + + + + + You'll need the following information to create your Oracle Database connection: + - `host` - The hostname or IP address of your Oracle Database server + - `port` - The port number your Oracle Database server is listening on (default: 1521) + - `database` - The Oracle Service Name or SID (System Identifier) for the database you are connecting to. For example: `ORCL`, `FREEPDB1`, `XEPDB1` + - `username` - The user name of the login created in the steps above + - `password` - The user 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 **OracleDB Connection** option. + ![Select OracleDB Connection](/images/app-connections/oracledb/select-oracledb-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to OracleDB**. + + + 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 user. + + + ![Create OracleDB Connection](/images/app-connections/oracledb/create-username-and-password-method.png) + + 4. Your **OracleDB Connection** is now available for use. + ![Assume User OracleDB Connection](/images/app-connections/oracledb/username-and-password-connection.png) + + + To create an Oracle Database Connection, make an API request to the [Create OracleDB Connection](/api-reference/endpoints/app-connections/oracledb/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 user. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/oracledb \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-oracledb-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1521, + "database": "FREEPDB1", + "username": "infisical", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-oracledb-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "oracledb", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1521, + "database": "FREEPDB1", + "username": "infisical", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/frameworks/packer.mdx b/docs/integrations/frameworks/packer.mdx new file mode 100644 index 000000000..dd4e3a0ff --- /dev/null +++ b/docs/integrations/frameworks/packer.mdx @@ -0,0 +1,95 @@ +--- +title: "Packer" +description: "Learn how to fetch secrets from Infisical with Packer using a data source" +--- + +This guide demonstrates how to use the Infisical Packer plugin to fetch secret data using a data source. The Packer plugin supports both [Infisical Cloud](https://app.infisical.com) and [self-hosted instances of Infisical](https://infisical.com/docs/self-hosting/overview). + +## Prerequisites + +Before you begin, make sure you have: + +- [Packer](https://developer.hashicorp.com/packer/install) installed +- An Infisical account with access to a project +- Basic understanding of Packer + +## Project Setup + +### Configure Provider + +First, specify the Infisical provider in your Packer configuration: + +```hcl +packer { + required_plugins { + infisical = { + source = "github.com/infisical/infisical" + version = ">=0.0.1" + } + } +} +``` + +### Authentication + +Using a Machine Identity, you can authenticate with [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth). + +```hcl +data "infisical-secrets" "dev-secrets" { + folder_path = "/" + env_slug = "dev" # The environment to list secrets from (e.g. dev, staging, prod) + project_id = "00000000-0000-0000-0000-000000000000" + host = "https://app.infisical.com" # Optional for cloud, required for self-hosted + + universal_auth { + client_id = "00000000-0000-0000-0000-000000000000" + client_secret = "..." # Optional if using INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET env variable + } +} +``` + +Learn more about [machine identities](/documentation/platform/identities/machine-identities). + +## Using Secrets in Packer + +You're able to fetch secrets from Infisical using the `infisical-secrets` Data Source: + +```hcl +# Fetch all secrets from a folder +data "infisical-secrets" "dev-secrets" { + folder_path = "/" + env_slug = "dev" + project_id = "00000000-0000-0000-0000-000000000000" + + universal_auth { + ... + } +} + +locals { + secrets = data.infisical-secrets.dev-secrets.secrets +} + +source "null" "basic-example" { + communicator = "none" +} + +build { + sources = [ + "source.null.basic-example" + ] + + provisioner "shell-local" { + inline = [ + "echo secret_key: ${local.secrets["SECRET_KEY"].secret_value}", + ] + } +} +``` + +The `local.secrets` object maps secret keys to [secret objects](https://github.com/Infisical/packer-plugin-infisical/blob/main/docs/datasources/secrets.md#secret-object). + +See also: +- [Packer Plugin Repository Example](https://github.com/Infisical/packer-plugin-infisical/blob/main/example/build.pkr.hcl) +- [Packer Plugin Repository Docs](https://github.com/Infisical/packer-plugin-infisical/tree/main/docs) +- [Machine Identity setup guide](/documentation/platform/identities/machine-identities) 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-devops.mdx b/docs/integrations/secret-syncs/azure-devops.mdx new file mode 100644 index 000000000..2f99fe128 --- /dev/null +++ b/docs/integrations/secret-syncs/azure-devops.mdx @@ -0,0 +1,143 @@ +--- +title: "Azure DevOps Sync" +description: "Learn how to configure a Azure DevOps Sync for Infisical." +--- + +**Prerequisites:** + +- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) +- Create an [Azure DevOps Connection](/integrations/app-connections/azure-devops) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Azure DevOps** option. + ![Select Azure DevOps](/images/secret-syncs/azure-devops/select-azure-devops-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/azure-devops/devops-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/azure-devops/devops-destination.png) + + - **Azure DevOps Connection**: The Azure DevOps Connection to authenticate with. + - **Project**: The Azure DevOps project to deploy secrets to. +

+ + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/azure-devops/devops-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + + Azure Devops 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. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Azure DevOps Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/azure-devops/devops-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Azure DevOps Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/azure-devops/devops-review.png) + + 8. If enabled, your Azure DevOps Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/azure-devops/devops-synced.png) + + + + To create a **Azure DevOps Sync**, make an API request to the [Create Azure DevOps Sync](/api-reference/endpoints/secret-syncs/azure-devops/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/azure-devops \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-devops-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": true + }, + "destinationConfig": { + "devopsProjectId": "12345678-90ab-cdef-1234-567890abcdef", + "devopsProjectName": "example-project" + } + }' + ``` + + ### Sample response + + ```json Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-devops-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "PIPELINE_${secretKey}", + "disableSecretDeletion": true + }, + "connection": { + "app": "azure-devops", + "name": "Production DevOps Organization", + "id": "8b92f5cc-3g77-5e80-6666-6ff57069385d" + }, + "environment": { + "slug": "production", + "name": "Production Environment", + "id": "4f16j9gg-7k11-9i23-2222-2jj91403729h" + }, + "folder": { + "id": "5a71e8dd-2f66-4d70-7777-7cc46958274c", + "path": "/devops/pipeline-secrets" + }, + "destination": "azure-devops", + "destinationConfig": { + "devopsProjectId": "12345678-90ab-cdef-1234-567890abcdef", + "devopsProjectName": "example-project" + } + } + } + ``` + + 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..0be08a9a9 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -34,6 +34,9 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **GCP Connection**: The GCP Connection to authenticate with. - **Project**: The GCP project to sync with. + - **Scope**: The GCP project scope that secrets should be synced to: + - **Global**: Secrets will be synced globally; available to all project regions. + - **Region**: Secrets will be synced to the specified region. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png) @@ -42,7 +45,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 65ec4e6cf..937c8d826 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -101,6 +101,10 @@ Key Schemas transform your secret keys by applying a prefix, suffix, or format p 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/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 98f3bfeb2..da9351188 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -176,6 +176,13 @@ Supports conditions and permission inversion | `read` | View secret versions and snapshots | | `create` | Roll back secrets to snapshots | +#### Subject: `commits` + +| Action | Description | +| -------- | ---------------------------------- | +| `read` | View commits and changes across folders | +| `perform-rollback` | Roll back commits changes and restore folders to previous state| + #### Subject: `secret-approval` | Action | Description | diff --git a/docs/mint.json b/docs/mint.json index 67a771085..1af808fea 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -200,6 +200,7 @@ "documentation/platform/secret-rotation/ldap-password", "documentation/platform/secret-rotation/mssql-credentials", "documentation/platform/secret-rotation/mysql-credentials", + "documentation/platform/secret-rotation/oracledb-credentials", "documentation/platform/secret-rotation/postgres-credentials" ] }, @@ -207,20 +208,21 @@ "group": "Dynamic Secrets", "pages": [ "documentation/platform/dynamic-secrets/overview", - "documentation/platform/dynamic-secrets/postgresql", - "documentation/platform/dynamic-secrets/mysql", - "documentation/platform/dynamic-secrets/mssql", - "documentation/platform/dynamic-secrets/oracle", - "documentation/platform/dynamic-secrets/cassandra", - "documentation/platform/dynamic-secrets/redis", "documentation/platform/dynamic-secrets/aws-elasticache", - "documentation/platform/dynamic-secrets/elastic-search", - "documentation/platform/dynamic-secrets/rabbit-mq", "documentation/platform/dynamic-secrets/aws-iam", + "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/elastic-search", + "documentation/platform/dynamic-secrets/gcp-iam", + "documentation/platform/dynamic-secrets/ldap", "documentation/platform/dynamic-secrets/mongo-atlas", "documentation/platform/dynamic-secrets/mongo-db", - "documentation/platform/dynamic-secrets/azure-entra-id", - "documentation/platform/dynamic-secrets/ldap", + "documentation/platform/dynamic-secrets/mssql", + "documentation/platform/dynamic-secrets/mysql", + "documentation/platform/dynamic-secrets/oracle", + "documentation/platform/dynamic-secrets/postgresql", + "documentation/platform/dynamic-secrets/rabbit-mq", + "documentation/platform/dynamic-secrets/redis", "documentation/platform/dynamic-secrets/sap-ase", "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", @@ -233,7 +235,8 @@ "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", @@ -328,6 +331,7 @@ { "group": "Machine Identities", "pages": [ + "documentation/platform/identities/alicloud-auth", "documentation/platform/identities/aws-auth", "documentation/platform/identities/azure-auth", "documentation/platform/identities/gcp-auth", @@ -340,6 +344,7 @@ "group": "OIDC Auth", "pages": [ "documentation/platform/identities/oidc-auth/general", + "documentation/platform/identities/oidc-auth/azure", "documentation/platform/identities/oidc-auth/github", "documentation/platform/identities/oidc-auth/circleci", "documentation/platform/identities/oidc-auth/gitlab", @@ -447,6 +452,8 @@ { "group": "Infrastructure Integrations", "pages": [ + "integrations/platforms/ansible", + "integrations/platforms/apache-airflow", { "group": "Container orchestrators", "pages": [ @@ -465,7 +472,6 @@ "integrations/platforms/ecs-with-agent" ] }, - "integrations/platforms/infisical-agent", { "group": "Docker", "pages": [ @@ -475,10 +481,10 @@ "integrations/platforms/docker-compose" ] }, - "integrations/frameworks/terraform", + "integrations/platforms/infisical-agent", + "integrations/frameworks/packer", "integrations/frameworks/pulumi", - "integrations/platforms/ansible", - "integrations/platforms/apache-airflow" + "integrations/frameworks/terraform" ] }, { @@ -493,6 +499,7 @@ "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", "integrations/app-connections/azure-client-secrets", + "integrations/app-connections/azure-devops", "integrations/app-connections/azure-key-vault", "integrations/app-connections/camunda", "integrations/app-connections/databricks", @@ -505,6 +512,7 @@ "integrations/app-connections/mssql", "integrations/app-connections/mysql", "integrations/app-connections/oci", + "integrations/app-connections/oracledb", "integrations/app-connections/postgres", "integrations/app-connections/teamcity", "integrations/app-connections/terraform-cloud", @@ -525,6 +533,7 @@ "integrations/secret-syncs/aws-parameter-store", "integrations/secret-syncs/aws-secrets-manager", "integrations/secret-syncs/azure-app-configuration", + "integrations/secret-syncs/azure-devops", "integrations/secret-syncs/azure-key-vault", "integrations/secret-syncs/camunda", "integrations/secret-syncs/databricks", @@ -724,6 +733,16 @@ "api-reference/endpoints/gcp-auth/revoke" ] }, + { + "group": "Alibaba Cloud Auth", + "pages": [ + "api-reference/endpoints/alicloud-auth/login", + "api-reference/endpoints/alicloud-auth/attach", + "api-reference/endpoints/alicloud-auth/retrieve", + "api-reference/endpoints/alicloud-auth/update", + "api-reference/endpoints/alicloud-auth/revoke" + ] + }, { "group": "AWS Auth", "pages": [ @@ -824,8 +843,7 @@ "api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/get-workspace", "api-reference/endpoints/workspaces/update-workspace", - "api-reference/endpoints/workspaces/secret-snapshots", - "api-reference/endpoints/workspaces/rollback-snapshot" + "api-reference/endpoints/workspaces/secret-snapshots" ] }, { @@ -925,6 +943,12 @@ { "group": "Dynamic Secrets", "pages": [ + { + "group": "Kubernetes", + "pages": [ + "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" + ] + }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", "api-reference/endpoints/dynamic-secrets/delete", @@ -1029,6 +1053,19 @@ "api-reference/endpoints/secret-rotations/mysql-credentials/update" ] }, + { + "group": "OracleDB Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/oracledb-credentials/create", + "api-reference/endpoints/secret-rotations/oracledb-credentials/delete", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/oracledb-credentials/list", + "api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/oracledb-credentials/update" + ] + }, { "group": "PostgreSQL Credentials", "pages": [ @@ -1048,8 +1085,8 @@ "group": "Secret Scanning", "pages": [ { - "group": "Data Sources", - "pages": [ + "group": "Data Sources", + "pages": [ "api-reference/endpoints/secret-scanning/data-sources/list", "api-reference/endpoints/secret-scanning/data-sources/options", { @@ -1070,15 +1107,15 @@ ] }, { - "group": "Findings", - "pages": [ + "group": "Findings", + "pages": [ "api-reference/endpoints/secret-scanning/findings/list", "api-reference/endpoints/secret-scanning/findings/update" ] }, { - "group": "Configuration", - "pages": [ + "group": "Configuration", + "pages": [ "api-reference/endpoints/secret-scanning/config/get-by-project-id", "api-reference/endpoints/secret-scanning/config/update" ] @@ -1177,6 +1214,18 @@ "api-reference/endpoints/app-connections/azure-client-secret/delete" ] }, + { + "group": "Azure DevOps", + "pages": [ + "api-reference/endpoints/app-connections/azure-devops/list", + "api-reference/endpoints/app-connections/azure-devops/available", + "api-reference/endpoints/app-connections/azure-devops/get-by-id", + "api-reference/endpoints/app-connections/azure-devops/get-by-name", + "api-reference/endpoints/app-connections/azure-devops/create", + "api-reference/endpoints/app-connections/azure-devops/update", + "api-reference/endpoints/app-connections/azure-devops/delete" + ] + }, { "group": "Azure Key Vault", "pages": [ @@ -1321,6 +1370,18 @@ "api-reference/endpoints/app-connections/oci/delete" ] }, + { + "group": "OracleDB", + "pages": [ + "api-reference/endpoints/app-connections/oracledb/list", + "api-reference/endpoints/app-connections/oracledb/available", + "api-reference/endpoints/app-connections/oracledb/get-by-id", + "api-reference/endpoints/app-connections/oracledb/get-by-name", + "api-reference/endpoints/app-connections/oracledb/create", + "api-reference/endpoints/app-connections/oracledb/update", + "api-reference/endpoints/app-connections/oracledb/delete" + ] + }, { "group": "PostgreSQL", "pages": [ @@ -1444,6 +1505,20 @@ "api-reference/endpoints/secret-syncs/azure-app-configuration/remove-secrets" ] }, + { + "group": "Azure DevOps", + "pages": [ + "api-reference/endpoints/secret-syncs/azure-devops/list", + "api-reference/endpoints/secret-syncs/azure-devops/get-by-id", + "api-reference/endpoints/secret-syncs/azure-devops/get-by-name", + "api-reference/endpoints/secret-syncs/azure-devops/create", + "api-reference/endpoints/secret-syncs/azure-devops/update", + "api-reference/endpoints/secret-syncs/azure-devops/delete", + "api-reference/endpoints/secret-syncs/azure-devops/sync-secrets", + "api-reference/endpoints/secret-syncs/azure-devops/import-secrets", + "api-reference/endpoints/secret-syncs/azure-devops/remove-secrets" + ] + }, { "group": "Azure Key Vault", "pages": [ diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index e0e5ba75d..f8a2a6864 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -29,7 +29,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete infisical: image: repository: infisical/infisical - tag: "v0.46.2-postgres" #<-- update + tag: "<>" #<-- select tag from Dockerhub from the above link pullPolicy: IfNotPresent ``` diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 8b6af1169..167e1c7b5 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -194,7 +194,7 @@ "section": { "api-key": { "api-keys": "Service Tokens", - "api-keys-description": "Every service token is specific to you, a certain project and a certain environment within this project.", + "api-keys-description": "Every service token is scoped to a given project's resources within the environment and path it has been granted access to.", "add-new": "Add New Token", "add-dialog": { "title": "Add an API Key", @@ -248,7 +248,7 @@ }, "token": { "service-tokens": "Service Tokens", - "service-tokens-description": "Every service token is specific to you, a certain project and a certain environment within this project.", + "service-tokens-description": "Every service token is scoped to a given project's resources within the environment and path it has been granted access to.", "add-new": "Add New Token", "add-dialog": { "title": "Add a service token for {{target}}", diff --git a/frontend/src/components/navigation/SecretDashboardPathBreadcrumb.tsx b/frontend/src/components/navigation/SecretDashboardPathBreadcrumb.tsx index 584a3f6c7..31a626866 100644 --- a/frontend/src/components/navigation/SecretDashboardPathBreadcrumb.tsx +++ b/frontend/src/components/navigation/SecretDashboardPathBreadcrumb.tsx @@ -14,13 +14,15 @@ type Props = { selectedPathSegmentIndex: number; environmentSlug: string; projectId: string; + disableCopy?: boolean; }; export const SecretDashboardPathBreadcrumb = ({ secretPathSegments, selectedPathSegmentIndex, environmentSlug, - projectId + projectId, + disableCopy }: Props) => { const [, isCopying, setIsCopying] = useTimedReset({ initialState: false @@ -32,7 +34,7 @@ export const SecretDashboardPathBreadcrumb = ({ return (

- {isLastItem ? ( + {isLastItem && !disableCopy ? (
{ case SecretRotation.PostgresCredentials: case SecretRotation.MySqlCredentials: case SecretRotation.MsSqlCredentials: + case SecretRotation.OracleDBCredentials: Component = ( = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.MySqlCredentials]: SqlCredentialsRotationParametersFields, + [SecretRotation.OracleDBCredentials]: SqlCredentialsRotationParametersFields, [SecretRotation.Auth0ClientSecret]: Auth0ClientSecretRotationParametersFields, [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlCredentialsRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlCredentialsRotationParametersFields.tsx index aa317d695..d22ab70e3 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlCredentialsRotationParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/shared/SqlCredentialsRotationParametersFields.tsx @@ -3,6 +3,7 @@ import { Controller, useFormContext } from "react-hook-form"; import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; import { FormControl, Input } from "@app/components/v2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; export const SqlCredentialsRotationParametersFields = () => { @@ -25,7 +26,15 @@ export const SqlCredentialsRotationParametersFields = () => { errorText={error?.message} label="Database Username 1" > - + )} control={control} @@ -38,7 +47,15 @@ export const SqlCredentialsRotationParametersFields = () => { errorText={error?.message} label="Database Username 2" > - + )} control={control} 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 98367eed3..17cc34f27 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -16,6 +16,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationReviewFields, [SecretRotation.MySqlCredentials]: SqlCredentialsRotationReviewFields, + [SecretRotation.OracleDBCredentials]: 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 f77dc99e5..428a99161 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -13,6 +13,7 @@ const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MsSqlCredentials]: SqlCredentialsRotationSecretsMappingFields, [SecretRotation.MySqlCredentials]: SqlCredentialsRotationSecretsMappingFields, + [SecretRotation.OracleDBCredentials]: 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 6d6fc64e2..77151bf1e 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -10,6 +10,8 @@ import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotati import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation"; +import { OracleDBCredentialsRotationSchema } from "./oracledb-credentials-rotation-schema"; + export const SecretRotationV2FormSchema = (isUpdate: boolean) => z .intersection( @@ -19,6 +21,7 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) => PostgresCredentialsRotationSchema, MsSqlCredentialsRotationSchema, MySqlCredentialsRotationSchema, + OracleDBCredentialsRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema ]), diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/oracledb-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/oracledb-credentials-rotation-schema.ts new file mode 100644 index 000000000..98574e0a6 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/oracledb-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 OracleDBCredentialsRotationSchema = z + .object({ + type: z.literal(SecretRotation.OracleDBCredentials) + }) + .merge(SqlCredentialsRotationSchema) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevOpsSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevOpsSyncFields.tsx new file mode 100644 index 000000000..4cc41057f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AzureDevOpsSyncFields.tsx @@ -0,0 +1,76 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { useGetAzureDevOpsProjects } from "@app/hooks/api/appConnections/azure"; +import { AzureDevOpsProject } from "@app/hooks/api/appConnections/azure/types"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const AzureDevOpsSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AzureDevOps } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: { projects } = { projects: [] }, isLoading: isProjectsLoading } = + useGetAzureDevOpsProjects(connectionId, { + enabled: Boolean(connectionId) + }); + + return ( + <> + { + setValue("destinationConfig.devopsProjectId", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + v.appId === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.appId ?? null); + setValue( + "destinationConfig.devopsProjectName", + (option as SingleValue)?.name ?? "" + ); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx index 2af03b203..3361b8384 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx @@ -5,27 +5,56 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; -import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; -import { useGcpConnectionListProjects } from "@app/hooks/api/appConnections/gcp/queries"; -import { TGitHubConnectionEnvironment } from "@app/hooks/api/appConnections/github"; +import { + Badge, + FilterableSelect, + FormControl, + Select, + SelectItem, + Tooltip +} from "@app/components/v2"; +import { GCP_SYNC_SCOPES } from "@app/helpers/secretSyncs"; +import { + useGcpConnectionListProjectLocations, + useGcpConnectionListProjects +} from "@app/hooks/api/appConnections/gcp/queries"; +import { TGcpLocation, TGcpProject } from "@app/hooks/api/appConnections/gcp/types"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { TSecretSyncForm } from "../schemas"; +const formatOptionLabel = ({ displayName, locationId }: TGcpLocation) => ( +
+ {displayName}{" "} + + {locationId} + +
+); + export const GcpSyncFields = () => { const { control, setValue } = useFormContext< TSecretSyncForm & { destination: SecretSync.GCPSecretManager } >(); const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + const selectedScope = useWatch({ name: "destinationConfig.scope", control }); const { data: projects, isPending } = useGcpConnectionListProjects(connectionId, { enabled: Boolean(connectionId) }); + const { data: locations, isPending: areLocationsPending } = useGcpConnectionListProjectLocations( + { connectionId, projectId }, + { + enabled: Boolean(connectionId) && Boolean(projectId) + } + ); + useEffect(() => { - setValue("destinationConfig.scope", GcpSyncScope.Global); + if (!selectedScope) setValue("destinationConfig.scope", GcpSyncScope.Global); }, []); return ( @@ -33,6 +62,7 @@ export const GcpSyncFields = () => { { setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.locationId", ""); }} /> { isLoading={isPending && Boolean(connectionId)} isDisabled={!connectionId} value={projects?.find((project) => project.id === value) ?? null} - onChange={(option) => - onChange((option as SingleValue)?.id ?? null) - } + onChange={(option) => { + setValue("destinationConfig.locationId", ""); + onChange((option as SingleValue)?.id ?? null); + }} options={projects} placeholder="Select a GCP project..." getOptionLabel={(option) => option.name} @@ -71,6 +102,76 @@ export const GcpSyncFields = () => { )} /> + ( + +

+ Specify how Infisical should sync secrets to GCP. The following options are + available: +

+
    + {Object.values(GCP_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+
+ } + tooltipClassName="max-w-lg" + label="Scope" + > + + + )} + /> + {selectedScope === GcpSyncScope.Region && ( + ( + + option.locationId === value) ?? null} + onChange={(option) => + onChange((option as SingleValue)?.locationId ?? null) + } + options={locations} + placeholder="Select a region..." + getOptionValue={(option) => option.locationId} + formatOptionLabel={formatOptionLabel} + /> + + )} + /> + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 48541b272..71405c448 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -7,6 +7,7 @@ import { OnePassSyncFields } from "./1PasswordSyncFields"; import { AwsParameterStoreSyncFields } from "./AwsParameterStoreSyncFields"; import { AwsSecretsManagerSyncFields } from "./AwsSecretsManagerSyncFields"; import { AzureAppConfigurationSyncFields } from "./AzureAppConfigurationSyncFields"; +import { AzureDevOpsSyncFields } from "./AzureDevOpsSyncFields"; import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { CamundaSyncFields } from "./CamundaSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; @@ -38,6 +39,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.AzureAppConfiguration: return ; + case SecretSync.AzureDevOps: + return ; case SecretSync.Databricks: return ; case SecretSync.Humanitec: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 6050cab21..0f6382edc 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -41,6 +41,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.GCPSecretManager: case SecretSync.AzureKeyVault: case SecretSync.AzureAppConfiguration: + case SecretSync.AzureDevOps: case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.TerraformCloud: @@ -131,7 +132,27 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { render={({ field: { value, onChange }, fieldState: { error } }) => ( + + When a secret is synced, values will be injected into the key schema before it + reaches the destination. This is useful for organization. + + +
+ Available keys: +
    +
  • + {"{{secretKey}}"} - The key of the secret +
  • +
  • + {"{{environment}}"} - The environment which the secret is in + (e.g. dev, staging, prod) +
  • +
+
+
+ } isError={Boolean(error)} isOptional errorText={error?.message} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevOpsSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevOpsSyncReviewFields.tsx new file mode 100644 index 000000000..8b6cf3145 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AzureDevOpsSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureDevOpsSyncReviewFields = () => { + const { watch } = useFormContext(); + const devopsProjectId = watch("destinationConfig.devopsProjectId"); + const devopsProjectName = watch("destinationConfig.devopsProjectName"); + + return ( + <> + {devopsProjectName} + {devopsProjectId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx index 000478f5e..bebb1ffe4 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/GcpSyncReviewFields.tsx @@ -3,12 +3,23 @@ import { useFormContext } from "react-hook-form"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; export const GcpSyncReviewFields = () => { const { watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.GCPSecretManager } >(); - const projectId = watch("destinationConfig.projectId"); + const destinationConfig = watch("destinationConfig"); - return {projectId}; + return ( + <> + {destinationConfig.projectId} + + {destinationConfig.scope} + + {destinationConfig.scope === GcpSyncScope.Region && ( + {destinationConfig.locationId} + )} + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index a53eec5e7..854555ffb 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -16,6 +16,7 @@ import { AwsSecretsManagerSyncReviewFields } from "./AwsSecretsManagerSyncReviewFields"; import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields"; +import { AzureDevOpsSyncReviewFields } from "./AzureDevOpsSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; @@ -70,6 +71,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.AzureAppConfiguration: DestinationFieldsComponent = ; break; + case SecretSync.AzureDevOps: + DestinationFieldsComponent = ; + break; case SecretSync.Databricks: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts new file mode 100644 index 000000000..6aa63ab8f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-devops-sync-destination-schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const AzureDevOpsSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.AzureDevOps), + destinationConfig: z.object({ + devopsProjectId: z.string().trim().min(1, { message: "Azure DevOps Project ID is required" }), + devopsProjectName: z + .string() + .trim() + .min(1, { message: "Azure DevOps Project Name is required" }) + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts index 75a5b68c1..1d925eb07 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts @@ -13,11 +13,27 @@ export const BaseSecretSyncSchema = - !val || /^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/.test(val), + (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 RegExp( + `^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$` + ); + const contentIsValid = allowedContentRegex.test(val); + + const secretKeyCount = (val.match(/\{\{secretKey\}\}/g) || []).length; + + return contentIsValid && secretKeyCount === 1; + }, { message: - "Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder." + "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." } ) }); diff --git a/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts index 4225c6619..fffae153e 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts @@ -7,9 +7,16 @@ import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; export const GcpSyncDestinationSchema = BaseSecretSyncSchema().merge( z.object({ destination: z.literal(SecretSync.GCPSecretManager), - destinationConfig: z.object({ - scope: z.literal(GcpSyncScope.Global), - projectId: z.string().min(1, "Project ID required") - }) + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GcpSyncScope.Global), + projectId: z.string().min(1, "Project ID required") + }), + z.object({ + scope: z.literal(GcpSyncScope.Region), + projectId: z.string().min(1, "Project ID required"), + locationId: z.string().min(1, "Region required") + }) + ]) }) ); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 792226dae..5dfb643b4 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -4,6 +4,7 @@ import { OnePassSyncDestinationSchema } from "./1password-sync-destination-schem import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; import { AwsSecretsManagerSyncDestinationSchema } from "./aws-secrets-manager-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; +import { AzureDevOpsSyncDestinationSchema } from "./azure-devops-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; @@ -24,6 +25,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ GcpSyncDestinationSchema, AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, + AzureDevOpsSyncDestinationSchema, DatabricksSyncDestinationSchema, HumanitecSyncDestinationSchema, TerraformCloudSyncDestinationSchema, diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index f5b462c3c..871985ce1 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -75,6 +75,18 @@ export const ROUTE_PATHS = Object.freeze({ "/secret-manager/$projectId/secrets/$envSlug", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/secrets/$envSlug" ), + RollbackPreviewPage: setRoute( + "/secret-manager/$projectId/commits/$environment/$folderId/$commitId/restore", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore" + ), + CommitDetailsPage: setRoute( + "/secret-manager/$projectId/commits/$environment/$folderId/$commitId", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId" + ), + CommitsPage: setRoute( + "/secret-manager/$projectId/commits/$environment/$folderId", + "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId" + ), OverviewPage: setRoute( "/secret-manager/$projectId/overview", "/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/overview" diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 2cbc5bd69..f9b2a828a 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -152,6 +152,11 @@ export enum PermissionConditionOperators { $ELEMENTMATCH = "$elemMatch" } +export enum ProjectPermissionCommitsActions { + Read = "read", + PerformRollback = "perform-rollback" +} + export type IdentityManagementSubjectFields = { identityId: string; }; @@ -229,6 +234,7 @@ export enum ProjectPermissionSub { Cmek = "cmek", SecretSyncs = "secret-syncs", Kmip = "kmip", + Commits = "commits", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", SecretScanningConfigs = "secret-scanning-configs" @@ -367,6 +373,7 @@ export type ProjectPermissionSet = | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms] | [ProjectPermissionKmipActions, ProjectPermissionSub.Kmip] + | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 54210438a..7a87de58c 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -15,6 +15,7 @@ import { AwsConnectionMethod, AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, + AzureDevOpsConnectionMethod, AzureKeyVaultConnectionMethod, CamundaConnectionMethod, DatabricksConnectionMethod, @@ -27,6 +28,7 @@ import { MsSqlConnectionMethod, MySqlConnectionMethod, OnePassConnectionMethod, + OracleDBConnectionMethod, PostgresConnectionMethod, TAppConnection, TeamCityConnectionMethod, @@ -60,6 +62,7 @@ export const APP_CONNECTION_MAP: Record< name: "Azure Client Secrets", image: "Microsoft Azure.png" }, + [AppConnection.AzureDevOps]: { name: "Azure DevOps", image: "Microsoft Azure.png" }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -67,6 +70,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, [AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" }, [AppConnection.MySql]: { name: "MySQL", image: "MySql.png" }, + [AppConnection.OracleDB]: { name: "OracleDB", image: "Oracle.png", enterprise: true }, [AppConnection.Camunda]: { name: "Camunda", image: "Camunda.png" }, [AppConnection.Windmill]: { name: "Windmill", image: "Windmill.png" }, [AppConnection.Auth0]: { name: "Auth0", image: "Auth0.png", size: 40 }, @@ -85,6 +89,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: + case AzureDevOpsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: @@ -106,9 +111,11 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: + case OracleDBConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: + case AzureDevOpsConnectionMethod.AccessToken: case WindmillConnectionMethod.AccessToken: return { name: "Access Token", icon: faKey }; case Auth0ConnectionMethod.ClientCredentials: diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index a654a1782..6e484881d 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -20,6 +20,11 @@ export const SECRET_ROTATION_MAP: Record< image: "MySql.png", size: 50 }, + [SecretRotation.OracleDBCredentials]: { + name: "OracleDB Credentials", + image: "Oracle.png", + size: 50 + }, [SecretRotation.Auth0ClientSecret]: { name: "Auth0 Client Secret", image: "Auth0.png", @@ -46,6 +51,7 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.PostgresCredentials]: true, [SecretRotation.MsSqlCredentials]: true, [SecretRotation.MySqlCredentials]: true, + [SecretRotation.OracleDBCredentials]: true, [SecretRotation.Auth0ClientSecret]: false, [SecretRotation.AzureClientSecret]: true, [SecretRotation.LdapPassword]: false, diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 88a0f7517..98a519e33 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -4,6 +4,7 @@ import { SecretSyncImportBehavior, SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; +import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; export const SECRET_SYNC_MAP: Record = { @@ -16,6 +17,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.GCPSecretManager]: AppConnection.GCP, [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, + [SecretSync.AzureDevOps]: AppConnection.AzureDevOps, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, [SecretSync.TerraformCloud]: AppConnection.TerraformCloud, @@ -124,3 +130,14 @@ export const HUMANITEC_SYNC_SCOPES: Record< "Infisical will sync secrets as environment level shared values to the specified Humanitec application environment." } }; + +export const GCP_SYNC_SCOPES: Record = { + [GcpSyncScope.Global]: { + name: "Global", + description: "Secrets will be synced globally; being available in all project regions." + }, + [GcpSyncScope.Region]: { + name: "Region", + description: "Secrets will be synced to the specified region." + } +}; diff --git a/frontend/src/hooks/api/appConnections/azure/queries.tsx b/frontend/src/hooks/api/appConnections/azure/queries.tsx index 98d1c2d29..8123806fd 100644 --- a/frontend/src/hooks/api/appConnections/azure/queries.tsx +++ b/frontend/src/hooks/api/appConnections/azure/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TAzureClient } from "./types"; +import { AzureDevOpsProjectsResponse, TAzureClient } from "./types"; const azureConnectionKeys = { all: [...appConnectionKeys.all, "azure"] as const, listClients: (connectionId: string) => - [...azureConnectionKeys.all, "clients", connectionId] as const + [...azureConnectionKeys.all, "clients", connectionId] as const, + listDevopsProjects: (connectionId: string) => + [...azureConnectionKeys.all, "devops-projects", connectionId] as const }; export const useAzureConnectionListClients = ( @@ -35,3 +37,29 @@ export const useAzureConnectionListClients = ( ...options }); }; + +export const fetchAzureDevOpsProjects = async ( + connectionId: string +): Promise => { + if (!connectionId) { + throw new Error("Connection ID is required"); + } + + const { data } = await apiRequest.get( + `/api/v1/app-connections/azure-devops/${connectionId}/projects` + ); + + return data; +}; + +export const useGetAzureDevOpsProjects = ( + connectionId: string, + options?: Omit, "queryKey" | "queryFn"> +) => { + return useQuery({ + queryKey: azureConnectionKeys.listDevopsProjects(connectionId), + queryFn: () => fetchAzureDevOpsProjects(connectionId), + enabled: Boolean(connectionId), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/azure/types.ts b/frontend/src/hooks/api/appConnections/azure/types.ts index 29da637f0..a5d21f047 100644 --- a/frontend/src/hooks/api/appConnections/azure/types.ts +++ b/frontend/src/hooks/api/appConnections/azure/types.ts @@ -3,3 +3,13 @@ export type TAzureClient = { appId: string; id: string; }; + +export interface AzureDevOpsProject { + id: string; + name: string; + appId: string; +} + +export interface AzureDevOpsProjectsResponse { + projects: AzureDevOpsProject[]; +} diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 6ed986453..1a3afe355 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", + AzureDevOps = "azure-devops", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", @@ -13,6 +14,7 @@ export enum AppConnection { Postgres = "postgres", MsSql = "mssql", MySql = "mysql", + OracleDB = "oracledb", Camunda = "camunda", Windmill = "windmill", Auth0 = "auth0", diff --git a/frontend/src/hooks/api/appConnections/gcp/queries.tsx b/frontend/src/hooks/api/appConnections/gcp/queries.tsx index a88860006..c8535aae1 100644 --- a/frontend/src/hooks/api/appConnections/gcp/queries.tsx +++ b/frontend/src/hooks/api/appConnections/gcp/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TGcpProject } from "./types"; +import { TGcpLocation, TGcpProject, TListProjectLocations } from "./types"; const gcpConnectionKeys = { all: [...appConnectionKeys.all, "gcp"] as const, listProjects: (connectionId: string) => - [...gcpConnectionKeys.all, "projects", connectionId] as const + [...gcpConnectionKeys.all, "projects", connectionId] as const, + listProjectLocations: ({ projectId, connectionId }: TListProjectLocations) => + [...gcpConnectionKeys.all, "project-locations", connectionId, projectId] as const }; export const useGcpConnectionListProjects = ( @@ -35,3 +37,29 @@ export const useGcpConnectionListProjects = ( ...options }); }; + +export const useGcpConnectionListProjectLocations = ( + { connectionId, projectId }: TListProjectLocations, + options?: Omit< + UseQueryOptions< + TGcpLocation[], + unknown, + TGcpLocation[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: gcpConnectionKeys.listProjectLocations({ connectionId, projectId }), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/gcp/${connectionId}/secret-manager-project-locations`, + { params: { projectId } } + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/gcp/types.ts b/frontend/src/hooks/api/appConnections/gcp/types.ts index 2af4eee9d..c5b594446 100644 --- a/frontend/src/hooks/api/appConnections/gcp/types.ts +++ b/frontend/src/hooks/api/appConnections/gcp/types.ts @@ -2,3 +2,13 @@ export type TGcpProject = { id: string; name: string; }; + +export type TListProjectLocations = { + connectionId: string; + projectId: string; +}; + +export type TGcpLocation = { + displayName: string; + locationId: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 9725e1a9c..0dcb11fa6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -45,6 +45,11 @@ export type TDatabricksConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Databricks; }; +export type TAzureDevOpsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureDevOps; + oauthClientId?: string; +}; + export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; @@ -69,6 +74,10 @@ export type TMySqlConnectionOption = TAppConnectionOptionBase & { app: AppConnection.MySql; }; +export type TOracleDBConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.OracleDB; +}; + export type TCamundaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Camunda; }; @@ -108,6 +117,7 @@ export type TAppConnectionOption = | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption | TAzureClientSecretsConnectionOption + | TAzureDevOpsConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption | TTerraformCloudConnectionOption @@ -115,6 +125,7 @@ export type TAppConnectionOption = | TPostgresConnectionOption | TMsSqlConnectionOption | TMySqlConnectionOption + | TOracleDBConnectionOption | TCamundaConnectionOption | TWindmillConnectionOption | TAuth0ConnectionOption @@ -131,6 +142,7 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnectionOption; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnectionOption; + [AppConnection.AzureDevOps]: TAzureDevOpsConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; @@ -138,6 +150,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; [AppConnection.MySql]: TMySqlConnectionOption; + [AppConnection.OracleDB]: TOracleDBConnectionOption; [AppConnection.Camunda]: TCamundaConnectionOption; [AppConnection.Windmill]: TWindmillConnectionOption; [AppConnection.Auth0]: TAuth0ConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts new file mode 100644 index 000000000..ef30936e6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AzureDevOpsConnectionMethod { + OAuth = "oauth", + AccessToken = "access-token" +} + +export type TAzureDevOpsConnection = TRootAppConnection & { + app: AppConnection.AzureDevOps; +} & ( + | { + method: AzureDevOpsConnectionMethod.OAuth; + credentials: { + code: string; + tenantId: string; + orgName: string; + }; + } + | { + method: AzureDevOpsConnectionMethod.AccessToken; + credentials: { + accessToken: string; + orgName: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index e88a59bf1..890562b6b 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -5,6 +5,7 @@ import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; +import { TAzureDevOpsConnection } from "./azure-devops-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TDatabricksConnection } from "./databricks-connection"; @@ -17,6 +18,7 @@ import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; import { TOCIConnection } from "./oci-connection"; +import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TTeamCityConnection } from "./teamcity-connection"; import { TTerraformCloudConnection } from "./terraform-cloud-connection"; @@ -28,6 +30,7 @@ export * from "./auth0-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; +export * from "./azure-devops-connection"; export * from "./azure-key-vault-connection"; export * from "./camunda-connection"; export * from "./databricks-connection"; @@ -40,6 +43,7 @@ export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; export * from "./oci-connection"; +export * from "./oracledb-connection"; export * from "./postgres-connection"; export * from "./teamcity-connection"; export * from "./terraform-cloud-connection"; @@ -54,6 +58,7 @@ export type TAppConnection = | TAzureKeyVaultConnection | TAzureAppConfigurationConnection | TAzureClientSecretsConnection + | TAzureDevOpsConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -61,6 +66,7 @@ export type TAppConnection = | TPostgresConnection | TMsSqlConnection | TMySqlConnection + | TOracleDBConnection | TCamundaConnection | TWindmillConnection | TAuth0Connection @@ -103,6 +109,7 @@ export type TAppConnectionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnection; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; + [AppConnection.AzureDevOps]: TAzureDevOpsConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; @@ -110,6 +117,7 @@ export type TAppConnectionMap = { [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; [AppConnection.MySql]: TMySqlConnection; + [AppConnection.OracleDB]: TOracleDBConnection; [AppConnection.Camunda]: TCamundaConnection; [AppConnection.Windmill]: TWindmillConnection; [AppConnection.Auth0]: TAuth0Connection; diff --git a/frontend/src/hooks/api/appConnections/types/oracledb-connection.ts b/frontend/src/hooks/api/appConnections/types/oracledb-connection.ts new file mode 100644 index 000000000..ba500ad04 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/oracledb-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +import { TBaseSqlConnectionCredentials } from "./shared"; + +export enum OracleDBConnectionMethod { + UsernameAndPassword = "username-and-password" +} + +export type TOracleDBConnection = TRootAppConnection & { app: AppConnection.OracleDB } & { + method: OracleDBConnectionMethod.UsernameAndPassword; + credentials: TBaseSqlConnectionCredentials; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index ca5a38e0b..696f5745f 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -52,6 +52,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_WEBHOOK]: "Create webhook", [EventType.UPDATE_WEBHOOK_STATUS]: "Update webhook status", [EventType.DELETE_WEBHOOK]: "Delete webhook", + [EventType.WEBHOOK_TRIGGERED]: "Webhook event", [EventType.GET_SECRET_IMPORTS]: "List secret imports", [EventType.CREATE_SECRET_IMPORT]: "Create secret import", [EventType.UPDATE_SECRET_IMPORT]: "Update secret import", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 67f0bac99..159a61808 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -65,6 +65,7 @@ export enum EventType { CREATE_WEBHOOK = "create-webhook", UPDATE_WEBHOOK_STATUS = "update-webhook-status", DELETE_WEBHOOK = "delete-webhook", + WEBHOOK_TRIGGERED = "webhook-triggered", GET_SECRET_IMPORTS = "get-secret-imports", CREATE_SECRET_IMPORT = "create-secret-import", UPDATE_SECRET_IMPORT = "update-secret-import", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 8774e1a73..006a3d7ca 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -427,6 +427,16 @@ interface DeleteWebhookEvent { }; } +export interface WebhookTriggeredEvent { + type: EventType.WEBHOOK_TRIGGERED; + metadata: { + webhookId: string; + status: string; + type: string; + payload: { [k: string]: string | null }; + }; +} + interface GetSecretImportsEvent { type: EventType.GET_SECRET_IMPORTS; metadata: { @@ -891,6 +901,7 @@ export type Event = | CreateWebhookEvent | UpdateWebhookStatusEvent | DeleteWebhookEvent + | WebhookTriggeredEvent | GetSecretImportsEvent | CreateSecretImportEvent | UpdateSecretImportEvent diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index f9aa6d4d0..e8d80e632 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -34,7 +34,13 @@ export enum DynamicSecretProviders { Totp = "totp", SapAse = "sap-ase", Kubernetes = "kubernetes", - Vertica = "vertica" + Vertica = "vertica", + GcpIam = "gcp-iam" +} + +export enum KubernetesDynamicSecretCredentialType { + Static = "static", + Dynamic = "dynamic" } export enum SqlProviders { @@ -44,6 +50,11 @@ export enum SqlProviders { MsSQL = "mssql" } +export enum DynamicSecretAwsIamAuth { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + export type TDynamicSecretProvider = | { type: DynamicSecretProviders.SqlDatabase; @@ -78,15 +89,26 @@ export type TDynamicSecretProvider = } | { type: DynamicSecretProviders.AwsIam; - inputs: { - accessKey: string; - secretAccessKey: string; - region: string; - awsPath?: string; - policyDocument?: string; - userGroups?: string; - policyArns?: string; - }; + inputs: + | { + method: DynamicSecretAwsIamAuth.AccessKey; + accessKey: string; + secretAccessKey: string; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; + } + | { + method: DynamicSecretAwsIamAuth.AssumeRole; + roleArn: string; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; + }; } | { type: DynamicSecretProviders.Redis; @@ -267,17 +289,32 @@ export type TDynamicSecretProvider = } | { type: DynamicSecretProviders.Kubernetes; - inputs: { - url: string; - clusterToken: string; - ca?: string; - serviceAccountName: string; - credentialType: "dynamic" | "static"; - namespace: string; - gatewayId?: string; - sslEnabled: boolean; - audiences: string[]; - }; + inputs: + | { + url?: string; + clusterToken?: string; + ca?: string; + serviceAccountName: string; + credentialType: KubernetesDynamicSecretCredentialType.Static; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + authMethod: string; + } + | { + url?: string; + clusterToken?: string; + ca?: string; + credentialType: KubernetesDynamicSecretCredentialType.Dynamic; + namespace: string; + gatewayId?: string; + sslEnabled: boolean; + audiences: string[]; + roleType: string; + role: string; + authMethod: string; + }; } | { type: DynamicSecretProviders.Vertica; @@ -290,6 +327,12 @@ export type TDynamicSecretProvider = creationStatement: string; revocationStatement: string; }; + } + | { + type: DynamicSecretProviders.GcpIam; + inputs: { + serviceAccountEmail: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/hooks/api/dynamicSecretLease/mutation.ts b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts index 1a95a3ab0..e7051bd2b 100644 --- a/frontend/src/hooks/api/dynamicSecretLease/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecretLease/mutation.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { DynamicSecretProviders } from "../dynamicSecret/types"; import { dynamicSecretLeaseKeys } from "./queries"; import { TCreateDynamicSecretLeaseDTO, @@ -19,6 +20,14 @@ export const useCreateDynamicSecretLease = () => { TCreateDynamicSecretLeaseDTO >({ mutationFn: async (dto) => { + if (dto.provider === DynamicSecretProviders.Kubernetes) { + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>( + "/api/v1/dynamic-secrets/leases/kubernetes", + dto + ); + return data; + } + const { data } = await apiRequest.post<{ lease: TDynamicSecretLease; data: unknown }>( "/api/v1/dynamic-secrets/leases", dto diff --git a/frontend/src/hooks/api/dynamicSecretLease/types.ts b/frontend/src/hooks/api/dynamicSecretLease/types.ts index 76bedc8b3..51ee64c4b 100644 --- a/frontend/src/hooks/api/dynamicSecretLease/types.ts +++ b/frontend/src/hooks/api/dynamicSecretLease/types.ts @@ -1,3 +1,5 @@ +import { DynamicSecretProviders } from "../dynamicSecret/types"; + export enum DynamicSecretLeaseStatus { FailedDeletion = "Failed to delete" } @@ -13,12 +15,20 @@ export type TDynamicSecretLease = { updatedAt: string; }; +export type TDynamicSecretKubernetesLeaseConfig = { + namespace?: string; +}; + +export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig; + export type TCreateDynamicSecretLeaseDTO = { dynamicSecretName: string; projectSlug: string; ttl?: string; path: string; environmentSlug: string; + config?: TDynamicSecretLeaseConfig; + provider: DynamicSecretProviders; }; export type TRenewDynamicSecretLeaseDTO = { diff --git a/frontend/src/hooks/api/folderCommits/index.tsx b/frontend/src/hooks/api/folderCommits/index.tsx new file mode 100644 index 000000000..ffdec812a --- /dev/null +++ b/frontend/src/hooks/api/folderCommits/index.tsx @@ -0,0 +1,2 @@ +export { useGetFolderCommitHistory, useGetFolderCommitsCount } from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/folderCommits/queries.tsx b/frontend/src/hooks/api/folderCommits/queries.tsx new file mode 100644 index 000000000..8d0883c5b --- /dev/null +++ b/frontend/src/hooks/api/folderCommits/queries.tsx @@ -0,0 +1,294 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { CommitHistoryItem, CommitWithChanges, RollbackPreview } from "./types"; + +export const commitKeys = { + count: ({ + workspaceId, + environment, + directory + }: { + workspaceId: string; + environment: string; + directory?: string; + }) => [{ workspaceId, environment, directory }, "folder-commits-count"] as const, + + history: ({ + workspaceId, + environment, + directory + }: { + workspaceId: string; + environment: string; + directory?: string; + }) => [{ workspaceId, environment, directory }, "folder-commits"] as const, + + details: ({ workspaceId, commitId }: { workspaceId: string; commitId: string }) => + [{ workspaceId, commitId }, "commit-details"] as const, + + rollbackPreview: ({ + folderId, + commitId, + envSlug, + projectId, + deepRollback + }: { + folderId: string; + commitId: string; + envSlug: string; + projectId: string; + deepRollback: boolean; + }) => [{ folderId, commitId, envSlug, projectId, deepRollback }, "rollback-preview"] as const +}; + +const fetchFolderCommitsCount = async ({ + workspaceId, + environment, + directory +}: { + workspaceId: string; + environment: string; + directory?: string; +}) => { + const res = await apiRequest.get<{ count: number; folderId: string }>( + "/api/v1/pit/commits/count", + { + params: { + environment, + path: directory, + projectId: workspaceId + } + } + ); + return res.data; +}; + +const fetchFolderCommitHistory = async ( + workspaceId: string, + environment: string, + directory: string, + offset: number = 0, + limit: number = 20, + search?: string, + sort: "asc" | "desc" = "desc" +): Promise<{ + commits: CommitHistoryItem[]; + total: number; + hasMore: boolean; +}> => { + const res = await apiRequest.get<{ + commits: CommitHistoryItem[]; + total: number; + hasMore: boolean; + }>("/api/v1/pit/commits", { + params: { + environment, + path: directory, + projectId: workspaceId, + offset, + limit, + search, + sort + } + }); + return res.data; +}; + +export const fetchCommitDetails = async (workspaceId: string, commitId: string) => { + const { data } = await apiRequest.get( + `/api/v1/pit/commits/${commitId}/changes`, + { + params: { + projectId: workspaceId + } + } + ); + return data; +}; + +export const fetchRollbackPreview = async ( + folderId: string, + commitId: string, + envSlug: string, + workspaceId: string, + deepRollback: boolean, + secretPath: string +): Promise => { + const { data } = await apiRequest.get( + `/api/v1/pit/commits/${commitId}/compare`, + { + params: { + folderId, + environment: envSlug, + deepRollback, + secretPath, + projectId: workspaceId + } + } + ); + return data; +}; + +const fetchRollback = async ( + folderId: string, + commitId: string, + workspaceId: string, + deepRollback: boolean, + message?: string, + envSlug?: string +) => { + const { data } = await apiRequest.post<{ success: boolean }>( + `/api/v1/pit/commits/${commitId}/rollback`, + { + folderId, + deepRollback, + message, + environment: envSlug, + projectId: workspaceId + } + ); + return data; +}; + +const fetchRevert = async (commitId: string, workspaceId: string) => { + const { data } = await apiRequest.post<{ success: boolean; message: string }>( + `/api/v1/pit/commits/${commitId}/revert`, + { + projectId: workspaceId + } + ); + return data; +}; + +export const useCommitRevert = ({ + commitId, + projectId, + environment, + directory +}: { + commitId: string; + projectId: string; + environment: string; + directory: string; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => fetchRevert(commitId, projectId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + commitKeys.details({ workspaceId: projectId, commitId }), + commitKeys.history({ workspaceId: projectId, environment, directory }), + commitKeys.count({ workspaceId: projectId, environment, directory }) + ] + }); + } + }); +}; + +export const useCommitRollback = ({ + workspaceId, + commitId, + folderId, + deepRollback, + environment, + directory, + envSlug +}: { + workspaceId: string; + commitId: string; + folderId: string; + deepRollback: boolean; + environment: string; + directory: string; + envSlug: string; +}) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (message: string) => + fetchRollback(folderId, commitId, workspaceId, deepRollback, message, envSlug), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ + commitKeys.details({ workspaceId, commitId }), + commitKeys.history({ workspaceId, environment, directory }), + commitKeys.count({ workspaceId, environment, directory }) + ] + }); + } + }); +}; + +export const useGetFolderCommitsCount = ({ + workspaceId, + environment, + directory, + isPaused +}: { + workspaceId: string; + environment: string; + directory: string; + isPaused?: boolean; +}) => + useQuery({ + enabled: Boolean(workspaceId && environment) && !isPaused, + queryKey: commitKeys.count({ workspaceId, environment, directory }), + queryFn: () => fetchFolderCommitsCount({ workspaceId, environment, directory }) + }); + +export const useGetFolderCommitHistory = ({ + workspaceId, + environment, + directory, + offset = 0, + limit = 20, + search, + sort = "desc" +}: { + workspaceId: string; + environment: string; + directory: string; + offset?: number; + limit?: number; + search?: string; + sort?: "asc" | "desc"; +}) => { + return useQuery({ + queryKey: [ + commitKeys.history({ workspaceId, environment, directory }), + offset, + limit, + search, + sort + ], + queryFn: () => + fetchFolderCommitHistory(workspaceId, environment, directory, offset, limit, search, sort), + enabled: Boolean(workspaceId && environment) + }); +}; + +export const useGetCommitDetails = (workspaceId: string, commitId: string) => { + return useQuery({ + queryKey: commitKeys.details({ workspaceId, commitId }), + queryFn: () => fetchCommitDetails(workspaceId, commitId), + enabled: Boolean(workspaceId) && Boolean(commitId) + }); +}; + +export const useGetRollbackPreview = ( + folderId: string, + commitId: string, + envSlug: string, + projectId: string, + deepRollback: boolean, + secretPath: string +) => { + return useQuery({ + queryKey: commitKeys.rollbackPreview({ folderId, commitId, envSlug, projectId, deepRollback }), + queryFn: () => + fetchRollbackPreview(folderId, commitId, envSlug, projectId, deepRollback, secretPath), + enabled: Boolean(folderId) && Boolean(commitId) + }); +}; diff --git a/frontend/src/hooks/api/folderCommits/types.ts b/frontend/src/hooks/api/folderCommits/types.ts new file mode 100644 index 000000000..878e3224d --- /dev/null +++ b/frontend/src/hooks/api/folderCommits/types.ts @@ -0,0 +1,64 @@ +import { CommitType, SecretVersions } from "../types"; + +export type CommitHistoryItem = { + id: string; + commitId: string; + actorMetadata: { + id: string; + name?: string; + }; + actorType: string; + message: string; + folderId: string; + envId: string; + createdAt: string; + updatedAt: string; + isLatest: boolean; +}; + +export type TFolderCommitChanges = { + id: string; + folderCommitId: string; + changeType: CommitType; + isUpdate: boolean; + secretVersionId: string | null; + folderVersionId: string | null; + createdAt: string; + updatedAt: string; + versions: SecretVersions[]; + secretKey?: string; + folderName?: string; + secretVersion?: string; + folderVersion?: string; +}; + +export type FolderReconstructedItem = { + type: string; + id: string; + versionId: string; + folderName?: string; + folderVersion?: number; + secretKey?: string; + secretVersion?: number; +}; + +export type CommitWithChanges = { + changes: CommitHistoryItem & { + changes: TFolderCommitChanges[]; + }; +}; + +export type RollbackChange = { + type: "folder" | "secret"; + id: string; + versionId: string; + changeType: "create" | "update" | "delete"; + commitId: string; +}; + +export type RollbackPreview = { + folderId: string; + folderName: string; + folderPath: string; + changes: RollbackChange[]; +}; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index eb10085bc..6fbe0ec13 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -21,7 +21,27 @@ export const groupKeys = { limit: number; search: string; filter?: EFilterReturnedUsers; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const, + specificProjectGroupUserMemberships: ({ + projectId, + slug, + offset, + limit, + search, + filter + }: { + slug: string; + projectId: string; + offset: number; + limit: number; + search: string; + filter?: EFilterReturnedUsers; + }) => + [ + ...groupKeys.forGroupUserMemberships(slug), + projectId, + { offset, limit, search, filter } + ] as const }; export const useGetGroupById = (groupId: string) => { @@ -80,3 +100,51 @@ export const useListGroupUsers = ({ } }); }; + +export const useListProjectGroupUsers = ({ + id, + projectId, + groupSlug, + offset = 0, + limit = 10, + search, + filter +}: { + id: string; + groupSlug: string; + projectId: string; + offset: number; + limit: number; + search: string; + filter?: EFilterReturnedUsers; +}) => { + return useQuery({ + queryKey: groupKeys.specificProjectGroupUserMemberships({ + slug: groupSlug, + projectId, + offset, + limit, + search, + filter + }), + enabled: Boolean(groupSlug), + placeholderData: (previousData) => previousData, + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + search, + ...(filter && { filter }) + }); + + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( + `/api/v2/workspace/${projectId}/groups/${id}/users`, + { + params + } + ); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 71f70806a..cc8d0cef6 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -5,6 +5,7 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", + [IdentityAuthMethod.ALICLOUD_AUTH]: "Alibaba Cloud Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", [IdentityAuthMethod.OCI_AUTH]: "OCI Auth", diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index a9b6eb3e1..de329d393 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -3,6 +3,7 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", + ALICLOUD_AUTH = "alicloud-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", OCI_AUTH = "oci-auth", diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index c08081f11..52fe52dc2 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { + AddIdentityAliCloudAuthDTO, AddIdentityAwsAuthDTO, AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, @@ -21,6 +22,7 @@ import { CreateIdentityUniversalAuthClientSecretRes, CreateTokenIdentityTokenAuthDTO, CreateTokenIdentityTokenAuthRes, + DeleteIdentityAliCloudAuthDTO, DeleteIdentityAwsAuthDTO, DeleteIdentityAzureAuthDTO, DeleteIdentityDTO, @@ -35,6 +37,7 @@ import { DeleteIdentityUniversalAuthDTO, Identity, IdentityAccessToken, + IdentityAliCloudAuth, IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, @@ -47,6 +50,7 @@ import { IdentityUniversalAuth, RevokeTokenDTO, RevokeTokenRes, + UpdateIdentityAliCloudAuthDTO, UpdateIdentityAwsAuthDTO, UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, @@ -553,6 +557,103 @@ export const useDeleteIdentityOciAuth = () => { }); }; +export const useAddIdentityAliCloudAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAliCloudAuth } + } = await apiRequest.post<{ identityAliCloudAuth: IdentityAliCloudAuth }>( + `/api/v1/auth/alicloud-auth/identities/${identityId}`, + { + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAliCloudAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId) + }); + } + }); +}; + +export const useUpdateIdentityAliCloudAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAliCloudAuth } + } = await apiRequest.patch<{ identityAliCloudAuth: IdentityAliCloudAuth }>( + `/api/v1/auth/alicloud-auth/identities/${identityId}`, + { + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAliCloudAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId) + }); + } + }); +}; + +export const useDeleteIdentityAliCloudAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identityAliCloudAuth } + } = await apiRequest.delete(`/api/v1/auth/alicloud-auth/identities/${identityId}`); + return identityAliCloudAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId) + }); + } + }); +}; + export const useUpdateIdentityOidcAuth = () => { const queryClient = useQueryClient(); return useMutation({ @@ -843,7 +944,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode }) => { const { data: { identityKubernetesAuth } @@ -860,7 +962,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode } ); @@ -950,7 +1053,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode }) => { const { data: { identityKubernetesAuth } @@ -967,7 +1071,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode } ); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index adc18ed6f..806c1f0a4 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -6,6 +6,7 @@ import { TReactQueryOptions } from "@app/types/reactQuery"; import { ClientSecretData, IdentityAccessToken, + IdentityAliCloudAuth, IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, @@ -33,6 +34,8 @@ export const identitiesKeys = { getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, getIdentityOidcAuth: (identityId: string) => [{ identityId }, "identity-oidc-auth"] as const, getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityAliCloudAuth: (identityId: string) => + [{ identityId }, "identity-alicloud-auth"] as const, getIdentityOciAuth: (identityId: string) => [{ identityId }, "identity-oci-auth"] as const, getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, @@ -193,6 +196,27 @@ export const useGetIdentityOciAuth = ( }); }; +export const useGetIdentityAliCloudAuth = ( + identityId: string, + options?: TReactQueryOptions["options"] +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId), + queryFn: async () => { + const { + data: { identityAliCloudAuth } + } = await apiRequest.get<{ identityAliCloudAuth: IdentityAliCloudAuth }>( + `/api/v1/auth/alicloud-auth/identities/${identityId}` + ); + return identityAliCloudAuth; + }, + staleTime: 0, + gcTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; + export const useGetIdentityAzureAuth = ( identityId: string, options?: TReactQueryOptions["options"] diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index c26466213..116030131 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -293,6 +293,45 @@ export type DeleteIdentityAwsAuthDTO = { identityId: string; }; +export type IdentityAliCloudAuth = { + identityId: string; + type: "iam"; + allowedArns: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAliCloudAuthDTO = { + organizationId: string; + identityId: string; + allowedArns: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAliCloudAuthDTO = { + organizationId: string; + identityId: string; + allowedArns: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityAliCloudAuthDTO = { + organizationId: string; + identityId: string; +}; + export type IdentityOciAuth = { identityId: string; type: "iam"; @@ -379,10 +418,16 @@ export type DeleteIdentityAzureAuthDTO = { identityId: string; }; +export enum IdentityKubernetesAuthTokenReviewMode { + Api = "api", + Gateway = "gateway" +} + export type IdentityKubernetesAuth = { identityId: string; kubernetesHost: string; tokenReviewerJwt: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -397,8 +442,9 @@ export type IdentityKubernetesAuth = { export type AddIdentityKubernetesAuthDTO = { organizationId: string; identityId: string; - kubernetesHost: string; + kubernetesHost: string | null; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -415,8 +461,9 @@ export type AddIdentityKubernetesAuthDTO = { export type UpdateIdentityKubernetesAuthDTO = { organizationId: string; identityId: string; - kubernetesHost?: string; + kubernetesHost?: string | null; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 8d662f993..6df9aa937 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -96,7 +96,7 @@ export const useUpdateOrgRole = () => { data: { role } } = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, { ...dto, - permissions: permissions?.length ? packRules(permissions) : undefined + permissions: permissions ? packRules(permissions) : undefined }); return role; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 3ac2574b5..241d3c1e2 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -11,7 +11,8 @@ export enum ApprovalStatus { export enum CommitType { DELETE = "delete", UPDATE = "update", - CREATE = "create" + CREATE = "create", + ADD = "add" } export type TSecretApprovalSecChangeData = { diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index b49fbd6cc..9e12f329d 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -10,6 +10,7 @@ import { import { apiRequest } from "@app/config/request"; import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { commitKeys } from "../folderCommits/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { TCreateFolderDTO, @@ -166,6 +167,9 @@ export const useCreateFolder = () => { queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId: projectId, environment, directory: path }) + }); } }); }; @@ -200,6 +204,12 @@ export const useUpdateFolder = () => { queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId: projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId: projectId, environment, directory: path }) + }); } }); }; @@ -234,6 +244,12 @@ export const useDeleteFolder = () => { queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ workspaceId: projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId: projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId: projectId, environment, directory: path }) + }); } }); }; @@ -279,6 +295,20 @@ export const useUpdateFolderBatch = () => { directory: folder.path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ + workspaceId: projectId, + environment: folder.environment, + directory: folder.path + }) + }); }); } }); diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 5daab0d9a..bb2765ffd 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -2,6 +2,7 @@ export enum SecretRotation { PostgresCredentials = "postgres-credentials", MsSqlCredentials = "mssql-credentials", MySqlCredentials = "mysql-credentials", + OracleDBCredentials = "oracledb-credentials", Auth0ClientSecret = "auth0-client-secret", AzureClientSecret = "azure-client-secret", LdapPassword = "ldap-password", diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 6f1a82c68..e4b3b6ee1 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -35,11 +35,16 @@ import { TMySqlCredentialsRotation, TMySqlCredentialsRotationGeneratedCredentialsResponse } from "./mysql-credentials-rotation"; +import { + TOracleDBCredentialsRotation, + TOracleDBCredentialsRotationGeneratedCredentialsResponse +} from "./oracledb-credentials-rotation"; export type TSecretRotationV2 = ( | TPostgresCredentialsRotation | TMsSqlCredentialsRotation | TMySqlCredentialsRotation + | TOracleDBCredentialsRotation | TAuth0ClientSecretRotation | TAzureClientSecretRotation | TLdapPasswordRotation @@ -63,6 +68,7 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TPostgresCredentialsRotationGeneratedCredentialsResponse | TMsSqlCredentialsRotationGeneratedCredentialsResponse | TMySqlCredentialsRotationGeneratedCredentialsResponse + | TOracleDBCredentialsRotationGeneratedCredentialsResponse | TAuth0ClientSecretRotationGeneratedCredentialsResponse | TAzureClientSecretRotationGeneratedCredentialsResponse | TLdapPasswordRotationGeneratedCredentialsResponse @@ -113,6 +119,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.PostgresCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MsSqlCredentials]: TSqlCredentialsRotationOption; [SecretRotation.MySqlCredentials]: TSqlCredentialsRotationOption; + [SecretRotation.OracleDBCredentials]: TSqlCredentialsRotationOption; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationOption; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationOption; [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; @@ -123,6 +130,7 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.PostgresCredentials]: TPostgresCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MsSqlCredentials]: TMsSqlCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.MySqlCredentials]: TMySqlCredentialsRotationGeneratedCredentialsResponse; + [SecretRotation.OracleDBCredentials]: TOracleDBCredentialsRotationGeneratedCredentialsResponse; [SecretRotation.Auth0ClientSecret]: TAuth0ClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.AzureClientSecret]: TAzureClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/oracledb-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/oracledb-credentials-rotation.ts new file mode 100644 index 000000000..80b9d7899 --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/oracledb-credentials-rotation.ts @@ -0,0 +1,17 @@ +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase, + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationProperties +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TOracleDBCredentialsRotation = TSecretRotationV2Base & { + type: SecretRotation.OracleDBCredentials; +} & TSqlCredentialsRotationProperties; + +export type TOracleDBCredentialsRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.OracleDBCredentials, + TSqlCredentialsRotationGeneratedCredentials + >; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts index 3be6679f0..a24242510 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/shared/sql-credentials-rotation.ts @@ -17,8 +17,13 @@ export type TSqlCredentialsRotationOption = { type: | SecretRotation.PostgresCredentials | SecretRotation.MsSqlCredentials - | SecretRotation.MySqlCredentials; - connection: AppConnection.Postgres | AppConnection.MsSql | AppConnection.MySql; + | SecretRotation.MySqlCredentials + | SecretRotation.OracleDBCredentials; + connection: + | AppConnection.Postgres + | AppConnection.MsSql + | AppConnection.MySql + | AppConnection.OracleDB; template: { secretsMapping: TSqlCredentialsRotationProperties["secretsMapping"]; createUserStatement: string; diff --git a/frontend/src/hooks/api/secretScanningV2/mutations.tsx b/frontend/src/hooks/api/secretScanningV2/mutations.tsx index 18bbbf236..beb1cffa0 100644 --- a/frontend/src/hooks/api/secretScanningV2/mutations.tsx +++ b/frontend/src/hooks/api/secretScanningV2/mutations.tsx @@ -115,6 +115,7 @@ export const useTriggerSecretScanningDataSource = () => { }); }; +// If possible, use useUpdateMultipleSecretScanningFinding instead. export const useUpdateSecretScanningFinding = () => { const queryClient = useQueryClient(); return useMutation({ @@ -140,6 +141,31 @@ export const useUpdateSecretScanningFinding = () => { }); }; +export const useUpdateMultipleSecretScanningFinding = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (findings: TUpdateSecretScanningFinding[]) => { + const { data } = await apiRequest.patch( + "/api/v2/secret-scanning/findings", + findings + ); + + return data.finding; + }, + onSuccess: (_, findings) => { + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.listFindings(findings[0].projectId) + }); + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.findingCount(findings[0].projectId) + }); + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.dataSource() + }); + } + }); +}; + export const useUpdateSecretScanningConfig = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 7185563d5..f381b3fca 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -5,6 +5,7 @@ export enum SecretSync { GCPSecretManager = "gcp-secret-manager", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", + AzureDevOps = "azure-devops", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts b/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts new file mode 100644 index 000000000..3567c507b --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/azure-devops-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TAzureDevOpsSync = TRootSecretSync & { + destination: SecretSync.AzureDevOps; + destinationConfig: { + devopsProjectId: string; + devopsProjectName: string; + }; + connection: { + app: AppConnection.AzureDevOps; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts index bda7da6be..b7b9c1db8 100644 --- a/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts @@ -3,15 +3,22 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; export enum GcpSyncScope { - Global = "global" + Global = "global", + Region = "region" } export type TGcpSync = TRootSecretSync & { destination: SecretSync.GCPSecretManager; - destinationConfig: { - scope: GcpSyncScope.Global; - projectId: string; - }; + destinationConfig: + | { + scope: GcpSyncScope.Global; + projectId: string; + } + | { + scope: GcpSyncScope.Region; + projectId: string; + locationId: string; + }; connection: { app: AppConnection.GCP; name: string; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f28a0820b..4f447f9df 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -5,6 +5,7 @@ import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; +import { TAzureDevOpsSync } from "./azure-devops-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; import { TDatabricksSync } from "./databricks-sync"; @@ -32,6 +33,7 @@ export type TSecretSync = | TGcpSync | TAzureKeyVaultSync | TAzureAppConfigurationSync + | TAzureDevOpsSync | TDatabricksSync | THumanitecSync | TTerraformCloudSync diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 3862d1f8d..82bf623e6 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -3,6 +3,7 @@ import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-qu import { apiRequest } from "@app/config/request"; import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { commitKeys } from "../folderCommits/queries"; import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries"; import { secretSnapshotKeys } from "../secretSnapshots/queries"; import { secretKeys } from "./queries"; @@ -59,6 +60,12 @@ export const useCreateSecretV3 = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -118,6 +125,12 @@ export const useUpdateSecretV3 = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -164,6 +177,12 @@ export const useDeleteSecretV3 = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -200,6 +219,12 @@ export const useCreateSecretBatch = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -236,6 +261,12 @@ export const useUpdateSecretBatch = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -274,6 +305,12 @@ export const useDeleteSecretBatch = ({ queryClient.invalidateQueries({ queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) }); }, ...options @@ -347,6 +384,20 @@ export const useMoveSecrets = ({ directory: sourceSecretPath }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ + workspaceId: projectId, + environment: sourceEnvironment, + directory: sourceSecretPath + }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ + workspaceId: projectId, + environment: sourceEnvironment, + directory: sourceSecretPath + }) + }); queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId: projectId }) }); diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index ea7376d3d..5cd7e3bbc 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -50,10 +50,13 @@ export const useUpdateGroupWorkspaceRole = () => { return groupMembership; }, - onSuccess: (_, { projectId }) => { + onSuccess: (_, { projectId, groupId }) => { queryClient.invalidateQueries({ queryKey: workspaceKeys.getWorkspaceGroupMemberships(projectId) }); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceGroupMembershipDetails(projectId, groupId) + }); } }); }; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index c040a1267..fa9240101 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -282,7 +282,8 @@ export const useUpdateProject = () => { newProjectName, newProjectDescription, newSlug, - secretSharing + secretSharing, + showSnapshotsLegacy }) => { const { data } = await apiRequest.patch<{ workspace: Workspace }>( `/api/v1/workspace/${projectID}`, @@ -290,7 +291,8 @@ export const useUpdateProject = () => { name: newProjectName, description: newProjectDescription, slug: newSlug, - secretSharing + secretSharing, + showSnapshotsLegacy } ); return data.workspace; @@ -691,6 +693,21 @@ export const useGetWorkspaceIdentityMembershipDetails = (projectId: string, iden }); }; +export const useGetWorkspaceGroupMembershipDetails = (projectId: string, groupId: string) => { + return useQuery({ + enabled: Boolean(projectId && groupId), + queryKey: workspaceKeys.getWorkspaceGroupMembershipDetails(projectId, groupId), + queryFn: async () => { + const { + data: { groupMembership } + } = await apiRequest.get<{ groupMembership: TGroupMembership }>( + `/api/v2/workspace/${projectId}/groups/${groupId}` + ); + return groupMembership; + } + }); +}; + export const useListWorkspaceGroups = (projectId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceGroupMemberships(projectId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index c10616b63..15317d2fd 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -36,6 +36,8 @@ export const workspaceKeys = { searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const, getWorkspaceGroupMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-groups"] as const, + getWorkspaceGroupMembershipDetails: (workspaceId: string, groupId: string) => + [{ workspaceId, groupId }, "workspace-group-membership-details"] as const, getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => [{ projectSlug }, "workspace-cas"] as const, specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) => diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 481bdcc08..fbaea7742 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -39,6 +39,7 @@ export type Workspace = { roles?: TProjectRole[]; hasDeleteProtection: boolean; secretSharing: boolean; + showSnapshotsLegacy: boolean; }; export type WorkspaceEnv = { @@ -79,6 +80,7 @@ export type UpdateProjectDTO = { newProjectDescription?: string; newSlug?: string; secretSharing?: boolean; + showSnapshotsLegacy?: boolean; }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index a736b52a7..ac194cd05 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -3,7 +3,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { Link, useNavigate, useRouter } from "@tanstack/react-router"; import axios from "axios"; import { addSeconds, formatISO } from "date-fns"; import { jwtDecode } from "jwt-decode"; @@ -51,6 +51,7 @@ export const SelectOrganizationSection = () => { const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); + const router = useRouter(); const queryParams = new URLSearchParams(window.location.search); const orgId = queryParams.get("org_id"); const callbackPort = queryParams.get("callback_port"); @@ -118,6 +119,8 @@ export const SelectOrganizationSection = () => { }) .finally(() => setIsInitialOrgCheckLoading(false)); + await router.invalidate(); + if (isMfaEnabled) { SecurityClient.setMfaToken(token); if (mfaMethod) { diff --git a/frontend/src/pages/auth/SelectOrgPage/route.tsx b/frontend/src/pages/auth/SelectOrgPage/route.tsx index 445479b3f..27ad4bb94 100644 --- a/frontend/src/pages/auth/SelectOrgPage/route.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/route.tsx @@ -7,7 +7,8 @@ import { SelectOrganizationPage } from "./SelectOrgPage"; export const SelectOrganizationPageQueryParams = z.object({ org_id: z.string().optional().catch(""), callback_port: z.coerce.number().optional().catch(undefined), - is_admin_login: z.boolean().optional().catch(false) + is_admin_login: z.boolean().optional().catch(false), + force: z.boolean().optional() }); export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({ diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index a01545537..fe27e5d7d 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -27,10 +27,9 @@ import { } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; +import { isLoggedIn } from "@app/hooks/api/reactQuery"; import { ProjectType } from "@app/hooks/api/workspace/types"; -import { navigateUserToOrg } from "../LoginPage/Login.utils"; - // eslint-disable-next-line new-cap const client = new jsrp.client(); @@ -71,6 +70,8 @@ export const SignupInvitePage = () => { const { mutateAsync: selectOrganization } = useSelectOrganization(); + const loggedIn = isLoggedIn(); + // Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria. const signupErrorCheck = async () => { setIsLoading(true); @@ -242,29 +243,10 @@ export const SignupInvitePage = () => { if (response?.token) { SecurityClient.setSignupToken(response.token); setStep(2); + } else if (loggedIn) { + navigate({ to: "/login/select-organization", search: { force: true } }); } else { - const redirectExistingUser = async () => { - try { - const { token: mfaToken, isMfaEnabled } = await selectOrganization({ - organizationId - }); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(mfaToken); - toggleShowMfa.on(); - setMfaSuccessCallback(() => redirectExistingUser); - return; - } - - // user will be redirected to dashboard - // if not logged in gets kicked out to login - await navigateUserToOrg(navigate, organizationId); - } catch (err) { - navigate({ to: "/login" }); - } - }; - - await redirectExistingUser(); + navigate({ to: "/login" }); } } } catch (err) { diff --git a/frontend/src/pages/middlewares/restrict-login-signup.tsx b/frontend/src/pages/middlewares/restrict-login-signup.tsx index f85ea44f2..73964868a 100644 --- a/frontend/src/pages/middlewares/restrict-login-signup.tsx +++ b/frontend/src/pages/middlewares/restrict-login-signup.tsx @@ -15,7 +15,8 @@ import { setAuthToken } from "@app/hooks/api/reactQuery"; import { ProjectType } from "@app/hooks/api/workspace/types"; const QueryParamsSchema = z.object({ - callback_port: z.coerce.number().optional().catch(undefined) + callback_port: z.coerce.number().optional().catch(undefined), + force: z.boolean().optional() }); export const AuthConsentWrapper = () => { @@ -71,7 +72,7 @@ export const AuthConsentWrapper = () => { export const Route = createFileRoute("/_restrict-login-signup")({ validateSearch: zodValidator(QueryParamsSchema), search: { - middlewares: [stripSearchParams({ callback_port: undefined })] + middlewares: [stripSearchParams({ callback_port: undefined, force: undefined })] }, beforeLoad: async ({ context, location, search }) => { if (!context.serverConfig.initialized) { @@ -90,6 +91,12 @@ export const Route = createFileRoute("/_restrict-login-signup")({ if (!data) return; setAuthToken(data.token); + + if (location.pathname === "/signupinvite") return; + + // Avoid redirect if on select-organization page with force=true + if (location.pathname.endsWith("select-organization") && search?.force === true) return; + // to do cli login if (search?.callback_port) { if (location.pathname.endsWith("select-organization") || location.pathname.endsWith("login")) diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index 61c4f4411..bb1d5010a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -5,6 +5,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { OrgPermissionGuardBanner } from "@app/components/permissions/OrgPermissionCan"; import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { @@ -72,6 +73,8 @@ export const AccessManagementPage = () => { } ]; + const hasNoAccess = tabSections.every((tab) => tab.isHidden); + return (
@@ -126,6 +129,7 @@ export const AccessManagementPage = () => { ))}
+ {hasNoAccess && } ); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx new file mode 100644 index 000000000..2d0cc1715 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx @@ -0,0 +1,349 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Tab, + TabList, + TabPanel, + Tabs +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityAliCloudAuth, + useGetIdentityAliCloudAuth, + useUpdateIdentityAliCloudAuth +} from "@app/hooks/api"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { IdentityFormTab } from "./types"; + +const schema = z + .object({ + allowedArns: z.string().min(1, "Required"), + accessTokenTTL: z + .string() + .refine( + (value) => Number(value) <= 315360000, + "Access Token TTL cannot be greater than 315360000" + ), + accessTokenMaxTTL: z + .string() + .refine( + (value) => Number(value) <= 315360000, + "Access Token Max TTL cannot be greater than 315360000" + ), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().max(50) + }) + .array() + .min(1) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityId?: string; + isUpdate?: boolean; +}; + +export const IdentityAliCloudAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityId, + isUpdate +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityAliCloudAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityAliCloudAuth(); + const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + + const { data } = useGetIdentityAliCloudAuth(identityId ?? "", { + enabled: isUpdate + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + allowedArns: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + allowedArns: data.allowedArns || "", + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + allowedArns: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + allowedArns, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityId) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + allowedArns, + identityId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId, + allowedArns, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
{ + setTabValue( + ["accessTokenTrustedIps"].includes(Object.keys(fields)[0]) + ? IdentityFormTab.Advanced + : IdentityFormTab.Configuration + ); + })} + > + setTabValue(value as IdentityFormTab)}> + + Configuration + Advanced + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + + {accessTokenTrustedIpsFields.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeAccessTokenTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+
+
+
+ + + +
+
+ ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 8f619029d..0d44bfe64 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -8,6 +8,7 @@ import { Badge, FormControl, Select, SelectItem, Tooltip } from "@app/components import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { IdentityAliCloudAuthForm } from "./IdentityAliCloudAuthForm"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; @@ -45,6 +46,7 @@ const identityAuthMethods = [ { label: "Universal Auth", value: IdentityAuthMethod.UNIVERSAL_AUTH }, { label: "Kubernetes Auth", value: IdentityAuthMethod.KUBERNETES_AUTH }, { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, + { label: "Alibaba Cloud Auth", value: IdentityAuthMethod.ALICLOUD_AUTH }, { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH }, { label: "OCI Auth", value: IdentityAuthMethod.OCI_AUTH }, @@ -172,6 +174,16 @@ export const IdentityAuthMethodModalContent = ({ ) }, + [IdentityAuthMethod.ALICLOUD_AUTH]: { + render: () => ( + + ) + }, + [IdentityAuthMethod.AWS_AUTH]: { render: () => ( Number(val) <= 315360000, { message: "Access Token TTL cannot be greater than 315360000" }), @@ -62,7 +68,26 @@ const schema = z ) .min(1) }) - .required(); + .superRefine((data, ctx) => { + if ( + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && + !data.kubernetesHost?.length + ) { + ctx.addIssue({ + path: ["kubernetesHost"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to API, a Kubernetes host must be provided" + }); + } + + 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" + }); + } + }); export type FormData = z.infer; @@ -100,11 +125,14 @@ export const IdentityKubernetesAuthForm = ({ control, handleSubmit, reset, + watch, + setValue, formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema), defaultValues: { + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api, kubernetesHost: "", tokenReviewerJwt: "", allowedNames: "", @@ -128,6 +156,7 @@ export const IdentityKubernetesAuthForm = ({ useEffect(() => { if (data) { reset({ + tokenReviewMode: data.tokenReviewMode, kubernetesHost: data.kubernetesHost, tokenReviewerJwt: data.tokenReviewerJwt, allowedNames: data.allowedNames, @@ -148,6 +177,7 @@ export const IdentityKubernetesAuthForm = ({ }); } else { reset({ + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api, kubernetesHost: "", tokenReviewerJwt: "", allowedNames: "", @@ -173,6 +203,7 @@ export const IdentityKubernetesAuthForm = ({ accessTokenMaxTTL, accessTokenNumUsesLimit, gatewayId, + tokenReviewMode, accessTokenTrustedIps }: FormData) => { try { @@ -181,7 +212,13 @@ export const IdentityKubernetesAuthForm = ({ if (data) { await updateMutateAsync({ organizationId: orgId, - kubernetesHost, + ...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api + ? { + kubernetesHost: kubernetesHost || "" + } + : { + kubernetesHost: null + }), tokenReviewerJwt: tokenReviewerJwt || null, allowedNames, allowedNamespaces, @@ -189,6 +226,7 @@ export const IdentityKubernetesAuthForm = ({ caCert, identityId, gatewayId: gatewayId || null, + tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -198,13 +236,20 @@ export const IdentityKubernetesAuthForm = ({ await addMutateAsync({ organizationId: orgId, identityId, - kubernetesHost: kubernetesHost || "", + ...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api + ? { + kubernetesHost: kubernetesHost || "" + } + : { + kubernetesHost: null + }), tokenReviewerJwt: tokenReviewerJwt || undefined, allowedNames: allowedNames || "", allowedNamespaces: allowedNamespaces || "", allowedAudience: allowedAudience || "", gatewayId: gatewayId || null, caCert: caCert || "", + tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -228,6 +273,8 @@ export const IdentityKubernetesAuthForm = ({ } }; + const tokenReviewMode = watch("tokenReviewMode"); + return (
{ @@ -235,6 +282,7 @@ export const IdentityKubernetesAuthForm = ({ [ "kubernetesHost", "tokenReviewerJwt", + "tokenReviewMode", "gatewayId", "accessTokenTTL", "accessTokenMaxTTL", @@ -253,37 +301,135 @@ export const IdentityKubernetesAuthForm = ({ Advanced - ( - +
+ - - - )} - /> - ( - - - - )} - /> + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+ + ( + + + + )} + /> + + {tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && ( + ( + + + + )} + /> + )} + + {tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api && ( + ( + + + + )} + /> + )} - - {(isAllowed) => ( - ( - - -
- -
-
-
- )} - /> - )} -
- )} /> - ( - -