diff --git a/.github/workflows/build-docker-image-to-prod.yml b/.github/workflows/build-docker-image-to-prod.yml deleted file mode 100644 index 3818fa1f8..000000000 --- a/.github/workflows/build-docker-image-to-prod.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Release production images (frontend, backend) -on: - push: - tags: - - "infisical/v*.*.*" - - "!infisical/v*.*.*-postgres" - -jobs: - backend-image: - name: Build backend image - runs-on: ubuntu-latest - steps: - - name: Extract version from tag - id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" - - name: โ˜๏ธ Checkout source - uses: actions/checkout@v3 - - name: ๐Ÿ“ฆ Install dependencies to test all dependencies - run: npm ci --only-production - working-directory: backend - # - name: ๐Ÿงช Run tests - # run: npm run test:ci - # working-directory: backend - - name: Save commit hashes for tag - id: commit - uses: pr-mpt/actions-commit-hash@v2 - - name: ๐Ÿ”ง Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: ๐Ÿ‹ Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Depot CLI - uses: depot/setup-action@v1 - - name: ๐Ÿ“ฆ Build backend and export to Docker - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - load: true - context: backend - tags: infisical/infisical:test - platforms: linux/amd64,linux/arm64 - - name: โป Spawn backend container and dependencies - run: | - docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull - - name: ๐Ÿงช Test backend image - run: | - ./.github/resources/healthcheck.sh infisical-backend-test - - name: โป Shut down backend container and dependencies - run: | - docker compose -f .github/resources/docker-compose.be-test.yml down - - name: ๐Ÿ—๏ธ Build backend and push - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - push: true - context: backend - tags: | - infisical/backend:${{ steps.commit.outputs.short }} - infisical/backend:latest - infisical/backend:${{ steps.extract_version.outputs.version }} - platforms: linux/amd64,linux/arm64 - - frontend-image: - name: Build frontend image - runs-on: ubuntu-latest - steps: - - name: Extract version from tag - id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" - - name: โ˜๏ธ Checkout source - uses: actions/checkout@v3 - - name: Save commit hashes for tag - id: commit - uses: pr-mpt/actions-commit-hash@v2 - - name: ๐Ÿ”ง Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: ๐Ÿ‹ Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Depot CLI - uses: depot/setup-action@v1 - - name: ๐Ÿ“ฆ Build frontend and export to Docker - uses: depot/build-push-action@v1 - with: - load: true - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - project: 64mmf0n610 - context: frontend - tags: infisical/frontend:test - platforms: linux/amd64,linux/arm64 - build-args: | - POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} - NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} - - name: โป Spawn frontend container - run: | - docker run -d --rm --name infisical-frontend-test infisical/frontend:test - - name: ๐Ÿงช Test frontend image - run: | - ./.github/resources/healthcheck.sh infisical-frontend-test - - name: โป Shut down frontend container - run: | - docker stop infisical-frontend-test - - name: ๐Ÿ—๏ธ Build frontend and push - uses: depot/build-push-action@v1 - with: - project: 64mmf0n610 - push: true - token: ${{ secrets.DEPOT_PROJECT_TOKEN }} - context: frontend - tags: | - infisical/frontend:${{ steps.commit.outputs.short }} - infisical/frontend:latest - infisical/frontend:${{ steps.extract_version.outputs.version }} - platforms: linux/amd64,linux/arm64 - build-args: | - POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} - NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} diff --git a/.github/workflows/nightly-tag-generation.yml b/.github/workflows/nightly-tag-generation.yml new file mode 100644 index 000000000..0a4eb3d97 --- /dev/null +++ b/.github/workflows/nightly-tag-generation.yml @@ -0,0 +1,82 @@ +name: Generate Nightly Tag + +on: + schedule: + - cron: '0 0 * * *' # Run daily at midnight UTC + workflow_dispatch: # Allow manual triggering for testing + +permissions: + contents: write + +jobs: + create-nightly-tag: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for tags + token: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Generate nightly tag + run: | + # Get the latest infisical production tag + LATEST_STABLE_TAG=$(git tag --list | grep "^v[0-9].*$" | grep -v "nightly" | sort -V | tail -n1) + + if [ -z "$LATEST_STABLE_TAG" ]; then + echo "No infisical production tags found, using v0.1.0" + LATEST_STABLE_TAG="v0.1.0" + fi + + echo "Latest production tag: $LATEST_STABLE_TAG" + + # Get current date in YYYYMMDD format + DATE=$(date +%Y%m%d) + + # Base nightly tag name + BASE_TAG="${LATEST_STABLE_TAG}-nightly-${DATE}" + + # Check if this exact tag already exists + if git tag --list | grep -q "^${BASE_TAG}$"; then + echo "Base tag ${BASE_TAG} already exists, finding next increment" + + # Find existing tags for this date and get the highest increment + EXISTING_TAGS=$(git tag --list | grep "^${BASE_TAG}" | grep -E '\.[0-9]+$' || true) + + if [ -z "$EXISTING_TAGS" ]; then + # No incremental tags exist, create .1 + NIGHTLY_TAG="${BASE_TAG}.1" + else + # Find the highest increment + HIGHEST_INCREMENT=$(echo "$EXISTING_TAGS" | sed "s|^${BASE_TAG}\.||" | sort -n | tail -n1) + NEXT_INCREMENT=$((HIGHEST_INCREMENT + 1)) + NIGHTLY_TAG="${BASE_TAG}.${NEXT_INCREMENT}" + fi + else + # Base tag doesn't exist, use it + NIGHTLY_TAG="$BASE_TAG" + fi + + echo "Generated nightly tag: $NIGHTLY_TAG" + echo "NIGHTLY_TAG=$NIGHTLY_TAG" >> $GITHUB_ENV + echo "LATEST_PRODUCTION_TAG=$LATEST_STABLE_TAG" >> $GITHUB_ENV + + git tag "$NIGHTLY_TAG" + git push origin "$NIGHTLY_TAG" + echo "โœ… Created and pushed nightly tag: $NIGHTLY_TAG" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.NIGHTLY_TAG }} + name: ${{ env.NIGHTLY_TAG }} + draft: false + prerelease: true + generate_release_notes: true + make_latest: false diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 7a73288cb..5c8809eb4 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -2,7 +2,9 @@ name: Release standalone docker image on: push: tags: - - "infisical/v*.*.*-postgres" + - "v*.*.*" + - "v*.*.*-nightly-*" + - "v*.*.*-nightly-*.*" jobs: infisical-tests: @@ -17,7 +19,7 @@ jobs: steps: - name: Extract version from tag id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" + run: echo "::set-output name=version::${GITHUB_REF_NAME}" - name: โ˜๏ธ Checkout source uses: actions/checkout@v3 with: @@ -53,7 +55,7 @@ jobs: push: true context: . tags: | - infisical/infisical:latest-postgres + infisical/infisical:latest infisical/infisical:${{ steps.commit.outputs.short }} infisical/infisical:${{ steps.extract_version.outputs.version }} platforms: linux/amd64,linux/arm64 @@ -69,7 +71,7 @@ jobs: steps: - name: Extract version from tag id: extract_version - run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}" + run: echo "::set-output name=version::${GITHUB_REF_NAME}" - name: โ˜๏ธ Checkout source uses: actions/checkout@v3 with: @@ -105,7 +107,7 @@ jobs: push: true context: . tags: | - infisical/infisical-fips:latest-postgres + infisical/infisical-fips:latest infisical/infisical-fips:${{ steps.commit.outputs.short }} infisical/infisical-fips:${{ steps.extract_version.outputs.version }} platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/run-backend-tests.yml b/.github/workflows/run-backend-tests.yml index f2ba04e76..0a71819f1 100644 --- a/.github/workflows/run-backend-tests.yml +++ b/.github/workflows/run-backend-tests.yml @@ -16,6 +16,16 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: + + + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + docker system prune -af + - name: โ˜๏ธ Checkout source uses: actions/checkout@v3 - uses: KengoTODA/actions-setup-docker-compose@v1 @@ -34,6 +44,8 @@ jobs: working-directory: backend - name: Start postgres and redis run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis + - name: Start Secret Rotation testing databases + run: docker compose -f docker-compose.e2e-dbs.yml up -d --wait --wait-timeout 300 - name: Run unit test run: npm run test:unit working-directory: backend @@ -41,6 +53,9 @@ jobs: run: npm run test:e2e working-directory: backend env: + E2E_TEST_ORACLE_DB_19_HOST: ${{ secrets.E2E_TEST_ORACLE_DB_19_HOST }} + E2E_TEST_ORACLE_DB_19_USERNAME: ${{ secrets.E2E_TEST_ORACLE_DB_19_USERNAME }} + E2E_TEST_ORACLE_DB_19_PASSWORD: ${{ secrets.E2E_TEST_ORACLE_DB_19_PASSWORD }} REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable AUTH_SECRET: something-random diff --git a/.infisicalignore b/.infisicalignore index fd4415178..66e2fb635 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -50,3 +50,4 @@ docs/integrations/app-connections/zabbix.mdx:generic-api-key:91 docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123 docs/integrations/app-connections/railway.mdx:generic-api-key:156 .github/workflows/validate-db-schemas.yml:generic-api-key:21 +k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts deleted file mode 100644 index 04a78bcd1..000000000 --- a/backend/e2e-test/mocks/queue.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TQueueServiceFactory } from "@app/queue"; - -export const mockQueue = (): TQueueServiceFactory => { - const queues: Record = {}; - const workers: Record = {}; - const job: Record = {}; - const events: Record = {}; - - return { - queue: async (name, jobData) => { - job[name] = jobData; - }, - queuePg: async () => {}, - schedulePg: async () => {}, - initialize: async () => {}, - shutdown: async () => undefined, - stopRepeatableJob: async () => true, - start: (name, jobFn) => { - queues[name] = jobFn; - workers[name] = jobFn; - }, - startPg: async () => {}, - listen: (name, event) => { - events[name] = event; - }, - getRepeatableJobs: async () => [], - getDelayedJobs: async () => [], - clearQueue: async () => {}, - stopJobById: async () => {}, - stopJobByIdPg: async () => {}, - stopRepeatableJobByJobId: async () => true, - stopRepeatableJobByKey: async () => true - }; -}; diff --git a/backend/e2e-test/routes/v3/secret-rotations.spec.ts b/backend/e2e-test/routes/v3/secret-rotations.spec.ts new file mode 100644 index 000000000..de539e5db --- /dev/null +++ b/backend/e2e-test/routes/v3/secret-rotations.spec.ts @@ -0,0 +1,726 @@ +/* eslint-disable no-promise-executor-return */ +/* eslint-disable no-await-in-loop */ +import knex from "knex"; +import { v4 as uuidv4 } from "uuid"; + +import { seedData1 } from "@app/db/seed-data"; + +enum SecretRotationType { + OracleDb = "oracledb", + MySQL = "mysql", + Postgres = "postgres" +} + +type TGenericSqlCredentials = { + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +type TSecretMapping = { + username: string; + password: string; +}; + +type TDatabaseUserCredentials = { + username: string; +}; + +const formatSqlUsername = (username: string) => `${username}_${uuidv4().slice(0, 8).replace(/-/g, "").toUpperCase()}`; + +const getSecretValue = async (secretKey: string) => { + const passwordSecret = await testServer.inject({ + url: `/api/v3/secrets/raw/${secretKey}`, + method: "GET", + query: { + workspaceId: seedData1.projectV3.id, + environment: seedData1.environment.slug + }, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + + expect(passwordSecret.statusCode).toBe(200); + expect(passwordSecret.json().secret).toBeDefined(); + + const passwordSecretJson = JSON.parse(passwordSecret.payload); + + return passwordSecretJson.secret.secretValue as string; +}; + +const deleteSecretRotation = async (id: string, type: SecretRotationType) => { + const res = await testServer.inject({ + method: "DELETE", + query: { + deleteSecrets: "true", + revokeGeneratedCredentials: "true" + }, + url: `/api/v2/secret-rotations/${type}-credentials/${id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + + expect(res.statusCode).toBe(200); +}; + +const deleteAppConnection = async (id: string, type: SecretRotationType) => { + const res = await testServer.inject({ + method: "DELETE", + url: `/api/v1/app-connections/${type}/${id}`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + } + }); + + expect(res.statusCode).toBe(200); +}; + +const createOracleDBAppConnection = async (credentials: TGenericSqlCredentials) => { + const createOracleDBAppConnectionReqBody = { + credentials: { + database: credentials.database, + host: credentials.host, + username: credentials.username, + password: credentials.password, + port: credentials.port, + sslEnabled: true, + sslRejectUnauthorized: true + }, + name: `oracle-db-${uuidv4()}`, + description: "Test OracleDB App Connection", + gatewayId: null, + isPlatformManagedCredentials: false, + method: "username-and-password" + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/app-connections/oracledb`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createOracleDBAppConnectionReqBody + }); + + const json = JSON.parse(res.payload); + + expect(res.statusCode).toBe(200); + expect(json.appConnection).toBeDefined(); + + return json.appConnection.id as string; +}; + +const createMySQLAppConnection = async (credentials: TGenericSqlCredentials) => { + const createMySQLAppConnectionReqBody = { + name: `mysql-test-${uuidv4()}`, + description: "test-mysql", + gatewayId: null, + method: "username-and-password", + credentials: { + host: credentials.host, + port: credentials.port, + database: credentials.database, + username: credentials.username, + password: credentials.password, + sslEnabled: false, + sslRejectUnauthorized: true + } + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/app-connections/mysql`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createMySQLAppConnectionReqBody + }); + + const json = JSON.parse(res.payload); + + expect(res.statusCode).toBe(200); + expect(json.appConnection).toBeDefined(); + + return json.appConnection.id as string; +}; + +const createPostgresAppConnection = async (credentials: TGenericSqlCredentials) => { + const createPostgresAppConnectionReqBody = { + credentials: { + host: credentials.host, + port: credentials.port, + database: credentials.database, + username: credentials.username, + password: credentials.password, + sslEnabled: false, + sslRejectUnauthorized: true + }, + name: `postgres-test-${uuidv4()}`, + description: "test-postgres", + gatewayId: null, + method: "username-and-password" + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/app-connections/postgres`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createPostgresAppConnectionReqBody + }); + + const json = JSON.parse(res.payload); + + expect(res.statusCode).toBe(200); + expect(json.appConnection).toBeDefined(); + + return json.appConnection.id as string; +}; + +const createOracleInfisicalUsers = async ( + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[] +) => { + const client = knex({ + client: "oracledb", + connection: { + database: credentials.database, + port: credentials.port, + host: credentials.host, + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: 10000, + ssl: { + // @ts-expect-error - this is a valid property for the ssl object + sslServerDNMatch: true + } + } + }); + + for await (const { username } of userCredentials) { + // check if user exists, and if it does, don't create it + const existingUser = await client.raw(`SELECT * FROM all_users WHERE username = '${username}'`); + + if (!existingUser.length) { + await client.raw(`CREATE USER ${username} IDENTIFIED BY "temporary_password"`); + } + await client.raw(`GRANT ALL PRIVILEGES TO ${username} WITH ADMIN OPTION`); + } + + await client.destroy(); +}; + +const createMySQLInfisicalUsers = async ( + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[] +) => { + const client = knex({ + client: "mysql2", + connection: { + database: credentials.database, + port: credentials.port, + host: credentials.host, + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: 10000 + } + }); + + // Fix: Ensure root has GRANT OPTION privileges + try { + await client.raw("GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;"); + await client.raw("FLUSH PRIVILEGES;"); + } catch (error) { + // Ignore if already has privileges + } + + for await (const { username } of userCredentials) { + // check if user exists, and if it does, dont create it + + const existingUser = await client.raw(`SELECT * FROM mysql.user WHERE user = '${username}'`); + + if (!existingUser[0].length) { + await client.raw(`CREATE USER '${username}'@'%' IDENTIFIED BY 'temporary_password';`); + } + + await client.raw(`GRANT ALL PRIVILEGES ON \`${credentials.database}\`.* TO '${username}'@'%';`); + await client.raw("FLUSH PRIVILEGES;"); + } + + await client.destroy(); +}; + +const createPostgresInfisicalUsers = async ( + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[] +) => { + const client = knex({ + client: "pg", + connection: { + database: credentials.database, + port: credentials.port, + host: credentials.host, + user: credentials.username, + password: credentials.password, + connectionTimeoutMillis: 10000 + } + }); + + for await (const { username } of userCredentials) { + // check if user exists, and if it does, don't create it + const existingUser = await client.raw("SELECT * FROM pg_catalog.pg_user WHERE usename = ?", [username]); + + if (!existingUser.rows.length) { + await client.raw(`CREATE USER "${username}" WITH PASSWORD 'temporary_password'`); + } + + await client.raw("GRANT ALL PRIVILEGES ON DATABASE ?? TO ??", [credentials.database, username]); + } + + await client.destroy(); +}; + +const createOracleDBSecretRotation = async ( + appConnectionId: string, + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[], + secretMapping: TSecretMapping +) => { + const now = new Date(); + const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago + + await createOracleInfisicalUsers(credentials, userCredentials); + + const createOracleDBSecretRotationReqBody = { + parameters: userCredentials.reduce( + (acc, user, index) => { + acc[`username${index + 1}`] = user.username; + return acc; + }, + {} as Record + ), + secretsMapping: { + username: secretMapping.username, + password: secretMapping.password + }, + name: `test-oracle-${uuidv4()}`, + description: "Test OracleDB Secret Rotation", + secretPath: "/", + isAutoRotationEnabled: true, + rotationInterval: 5, // 5 seconds for testing + rotateAtUtc: { + hours: rotationTime.getUTCHours(), + minutes: rotationTime.getUTCMinutes() + }, + connectionId: appConnectionId, + environment: seedData1.environment.slug, + projectId: seedData1.projectV3.id + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/secret-rotations/oracledb-credentials`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createOracleDBSecretRotationReqBody + }); + + expect(res.statusCode).toBe(200); + expect(res.json().secretRotation).toBeDefined(); + + return res; +}; + +const createMySQLSecretRotation = async ( + appConnectionId: string, + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[], + secretMapping: TSecretMapping +) => { + const now = new Date(); + const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago + + await createMySQLInfisicalUsers(credentials, userCredentials); + + const createMySQLSecretRotationReqBody = { + parameters: userCredentials.reduce( + (acc, user, index) => { + acc[`username${index + 1}`] = user.username; + return acc; + }, + {} as Record + ), + secretsMapping: { + username: secretMapping.username, + password: secretMapping.password + }, + name: `test-mysql-rotation-${uuidv4()}`, + description: "Test MySQL Secret Rotation", + secretPath: "/", + isAutoRotationEnabled: true, + rotationInterval: 5, + rotateAtUtc: { + hours: rotationTime.getUTCHours(), + minutes: rotationTime.getUTCMinutes() + }, + connectionId: appConnectionId, + environment: seedData1.environment.slug, + projectId: seedData1.projectV3.id + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/secret-rotations/mysql-credentials`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createMySQLSecretRotationReqBody + }); + + expect(res.statusCode).toBe(200); + expect(res.json().secretRotation).toBeDefined(); + + return res; +}; + +const createPostgresSecretRotation = async ( + appConnectionId: string, + credentials: TGenericSqlCredentials, + userCredentials: TDatabaseUserCredentials[], + secretMapping: TSecretMapping +) => { + const now = new Date(); + const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago + + await createPostgresInfisicalUsers(credentials, userCredentials); + + const createPostgresSecretRotationReqBody = { + parameters: userCredentials.reduce( + (acc, user, index) => { + acc[`username${index + 1}`] = user.username; + return acc; + }, + {} as Record + ), + secretsMapping: { + username: secretMapping.username, + password: secretMapping.password + }, + name: `test-postgres-rotation-${uuidv4()}`, + description: "Test Postgres Secret Rotation", + secretPath: "/", + isAutoRotationEnabled: true, + rotationInterval: 5, + rotateAtUtc: { + hours: rotationTime.getUTCHours(), + minutes: rotationTime.getUTCMinutes() + }, + connectionId: appConnectionId, + environment: seedData1.environment.slug, + projectId: seedData1.projectV3.id + }; + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/secret-rotations/postgres-credentials`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: createPostgresSecretRotationReqBody + }); + + expect(res.statusCode).toBe(200); + expect(res.json().secretRotation).toBeDefined(); + + return res; +}; + +describe("Secret Rotations", async () => { + const testCases = [ + { + type: SecretRotationType.MySQL, + name: "MySQL (8.4.6) Secret Rotation", + dbCredentials: { + database: "mysql-test", + host: "127.0.0.1", + username: "root", + password: "mysql-test", + port: 3306 + }, + secretMapping: { + username: formatSqlUsername("MYSQL_USERNAME"), + password: formatSqlUsername("MYSQL_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("MYSQL_USER_1") + }, + { + username: formatSqlUsername("MYSQL_USER_2") + } + ] + }, + { + type: SecretRotationType.MySQL, + name: "MySQL (8.0.29) Secret Rotation", + dbCredentials: { + database: "mysql-test", + host: "127.0.0.1", + username: "root", + password: "mysql-test", + port: 3307 + }, + secretMapping: { + username: formatSqlUsername("MYSQL_USERNAME"), + password: formatSqlUsername("MYSQL_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("MYSQL_USER_1") + }, + { + username: formatSqlUsername("MYSQL_USER_2") + } + ] + }, + { + type: SecretRotationType.MySQL, + name: "MySQL (5.7.31) Secret Rotation", + dbCredentials: { + database: "mysql-test", + host: "127.0.0.1", + username: "root", + password: "mysql-test", + port: 3308 + }, + secretMapping: { + username: formatSqlUsername("MYSQL_USERNAME"), + password: formatSqlUsername("MYSQL_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("MYSQL_USER_1") + }, + { + username: formatSqlUsername("MYSQL_USER_2") + } + ] + }, + { + type: SecretRotationType.OracleDb, + name: "OracleDB (23.8) Secret Rotation", + dbCredentials: { + database: "FREEPDB1", + host: "127.0.0.1", + username: "system", + password: "pdb-password", + port: 1521 + }, + secretMapping: { + username: formatSqlUsername("ORACLEDB_USERNAME"), + password: formatSqlUsername("ORACLEDB_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("INFISICAL_USER_1") + }, + { + username: formatSqlUsername("INFISICAL_USER_2") + } + ] + }, + { + type: SecretRotationType.OracleDb, + name: "OracleDB (19.3) Secret Rotation", + skippable: true, + dbCredentials: { + password: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD!, + host: process.env.E2E_TEST_ORACLE_DB_19_HOST!, + username: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!, + port: 1521, + database: "ORCLPDB1" + }, + secretMapping: { + username: formatSqlUsername("ORACLEDB_USERNAME"), + password: formatSqlUsername("ORACLEDB_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("INFISICAL_USER_1") + }, + { + username: formatSqlUsername("INFISICAL_USER_2") + } + ] + }, + { + type: SecretRotationType.Postgres, + name: "Postgres (17) Secret Rotation", + dbCredentials: { + database: "postgres-test", + host: "127.0.0.1", + username: "postgres-test", + password: "postgres-test", + port: 5433 + }, + secretMapping: { + username: formatSqlUsername("POSTGRES_USERNAME"), + password: formatSqlUsername("POSTGRES_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("INFISICAL_USER_1") + }, + { + username: formatSqlUsername("INFISICAL_USER_2") + } + ] + }, + { + type: SecretRotationType.Postgres, + name: "Postgres (16) Secret Rotation", + dbCredentials: { + database: "postgres-test", + host: "127.0.0.1", + username: "postgres-test", + password: "postgres-test", + port: 5434 + }, + secretMapping: { + username: formatSqlUsername("POSTGRES_USERNAME"), + password: formatSqlUsername("POSTGRES_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("INFISICAL_USER_1") + }, + { + username: formatSqlUsername("INFISICAL_USER_2") + } + ] + }, + { + type: SecretRotationType.Postgres, + name: "Postgres (10.12) Secret Rotation", + dbCredentials: { + database: "postgres-test", + host: "127.0.0.1", + username: "postgres-test", + password: "postgres-test", + port: 5435 + }, + secretMapping: { + username: formatSqlUsername("POSTGRES_USERNAME"), + password: formatSqlUsername("POSTGRES_PASSWORD") + }, + userCredentials: [ + { + username: formatSqlUsername("INFISICAL_USER_1") + }, + { + username: formatSqlUsername("INFISICAL_USER_2") + } + ] + } + ] as { + skippable?: boolean; + type: SecretRotationType; + name: string; + dbCredentials: TGenericSqlCredentials; + secretMapping: TSecretMapping; + userCredentials: TDatabaseUserCredentials[]; + }[]; + + const createAppConnectionMap = { + [SecretRotationType.OracleDb]: createOracleDBAppConnection, + [SecretRotationType.MySQL]: createMySQLAppConnection, + [SecretRotationType.Postgres]: createPostgresAppConnection + }; + + const createRotationMap = { + [SecretRotationType.OracleDb]: createOracleDBSecretRotation, + [SecretRotationType.MySQL]: createMySQLSecretRotation, + [SecretRotationType.Postgres]: createPostgresSecretRotation + }; + + const appConnectionIds: { id: string; type: SecretRotationType }[] = []; + const secretRotationIds: { id: string; type: SecretRotationType }[] = []; + + afterAll(async () => { + for (const { id, type } of secretRotationIds) { + await deleteSecretRotation(id, type); + } + + for (const { id, type } of appConnectionIds) { + await deleteAppConnection(id, type); + } + }); + + testCases.forEach(({ skippable, dbCredentials, secretMapping, userCredentials, type, name }) => { + const shouldSkip = () => { + if (skippable) { + if (type === SecretRotationType.OracleDb) { + if (!process.env.E2E_TEST_ORACLE_DB_19_HOST) { + return true; + } + } + } + + return false; + }; + + if (shouldSkip()) { + test.skip(`Skipping Secret Rotation for ${type} (${name}) because E2E_TEST_ORACLE_DB_19_HOST is not set`); + } else { + test.concurrent( + `Create secret rotation for ${name}`, + async () => { + const appConnectionId = await createAppConnectionMap[type](dbCredentials); + + if (appConnectionId) { + appConnectionIds.push({ id: appConnectionId, type }); + } + + const res = await createRotationMap[type](appConnectionId, dbCredentials, userCredentials, secretMapping); + + const resJson = JSON.parse(res.payload); + + if (resJson.secretRotation) { + secretRotationIds.push({ id: resJson.secretRotation.id, type }); + } + + const startSecretValue = await getSecretValue(secretMapping.password); + expect(startSecretValue).toBeDefined(); + + let attempts = 0; + while (attempts < 60) { + const currentSecretValue = await getSecretValue(secretMapping.password); + + if (currentSecretValue !== startSecretValue) { + break; + } + + attempts += 1; + await new Promise((resolve) => setTimeout(resolve, 2_500)); + } + + if (attempts >= 60) { + throw new Error("Secret rotation failed to rotate after 60 attempts"); + } + + const finalSecretValue = await getSecretValue(secretMapping.password); + expect(finalSecretValue).not.toBe(startSecretValue); + }, + { + timeout: 300_000 + } + ); + } + }); +}); diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 60e70d379..ff5f42286 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -18,6 +18,7 @@ import { keyStoreFactory } from "@app/keystore/keystore"; import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { buildRedisFromConfig } from "@app/lib/config/redis"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; +import { bootstrapCheck } from "@app/server/boot-strap-check"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -63,6 +64,8 @@ export default { const queue = queueServiceFactory(envCfg, { dbConnectionUrl: envCfg.DB_CONNECTION_URI }); const keyStore = keyStoreFactory(envCfg); + await queue.initialize(); + const hsmModule = initializeHsmModule(envCfg); hsmModule.initialize(); @@ -78,9 +81,13 @@ export default { envConfig: envCfg }); + await bootstrapCheck({ db }); + // @ts-expect-error type globalThis.testServer = server; // @ts-expect-error type + globalThis.testQueue = queue; + // @ts-expect-error type globalThis.testSuperAdminDAL = superAdminDAL; // @ts-expect-error type globalThis.jwtAuthToken = crypto.jwt().sign( @@ -105,6 +112,8 @@ export default { // custom setup return { async teardown() { + // @ts-expect-error type + await globalThis.testQueue.shutdown(); // @ts-expect-error type await globalThis.testServer.close(); // @ts-expect-error type @@ -112,7 +121,9 @@ export default { // @ts-expect-error type delete globalThis.testSuperAdminDAL; // @ts-expect-error type - delete globalThis.jwtToken; + delete globalThis.jwtAuthToken; + // @ts-expect-error type + delete globalThis.testQueue; // called after all tests with this env have been run await db.migrate.rollback( { diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index adf9489d4..c25d8d4d1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -148,6 +148,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index fbead3a24..e08f811ae 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise => { t.index("expiresAt"); t.index("orgId"); t.index("projectId"); + t.index("eventType"); + t.index("userAgentType"); + t.index("actor"); }); console.log("Adding GIN indices..."); @@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise => { console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); - // create partitions 4 years ahead - const partitionMonths = 4 * 12; + // create partitions 20 years ahead + const partitionMonths = 20 * 12; const partitionPromises: Promise[] = []; for (let x = 1; x <= partitionMonths; x += 1) { partitionPromises.push( diff --git a/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts b/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts index b720c97ae..8c6dc8728 100644 --- a/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts +++ b/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts @@ -2,7 +2,7 @@ import { Knex } from "knex"; import { chunkArray } from "@app/lib/fn"; -import { logger } from "@app/lib/logger"; +import { initLogger, logger } from "@app/lib/logger"; import { TableName } from "../schemas"; import { TReminders, TRemindersInsert } from "../schemas/reminders"; @@ -107,5 +107,6 @@ export async function up(knex: Knex): Promise { } export async function down(): Promise { + initLogger(); logger.info("Rollback not implemented for secret reminders fix migration"); } diff --git a/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts b/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts new file mode 100644 index 000000000..218260b3e --- /dev/null +++ b/backend/src/db/migrations/20250808021941_access-policy-max-time-period.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas/models"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod"))) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.string("maxTimePeriod").nullable(); // Ex: 1h - Null is permanent + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod")) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("maxTimePeriod"); + }); + } +} diff --git a/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts new file mode 100644 index 000000000..687ee5570 --- /dev/null +++ b/backend/src/db/migrations/20250813020335_access-request-edit-cols.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (!hasEditNoteCol || !hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (!hasEditedByUserId) { + t.uuid("editedByUserId").nullable(); + t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + + if (!hasEditNoteCol) { + t.string("editNote").nullable(); + } + }); + } +} + +export async function down(knex: Knex): Promise { + const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote"); + const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId"); + + if (hasEditNoteCol || hasEditedByUserId) { + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + if (hasEditedByUserId) { + t.dropColumn("editedByUserId"); + } + + if (hasEditNoteCol) { + t.dropColumn("editNote"); + } + }); + } +} diff --git a/backend/src/db/migrations/20250813214709_enforce-google-sso.ts b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts new file mode 100644 index 000000000..2346c91a4 --- /dev/null +++ b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced"; +const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed"; +export async function up(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (!hasGoogleSsoAuthEnforcedColumn) + table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable(); + if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME); + if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index ea57c54d2..66ed79ab7 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -17,7 +17,8 @@ export const AccessApprovalPoliciesSchema = z.object({ updatedAt: z.date(), enforcementLevel: z.string().default("hard"), deletedAt: z.date().nullable().optional(), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + maxTimePeriod: z.string().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/access-approval-requests.ts b/backend/src/db/schemas/access-approval-requests.ts index 6a6f09148..14997a974 100644 --- a/backend/src/db/schemas/access-approval-requests.ts +++ b/backend/src/db/schemas/access-approval-requests.ts @@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({ requestedByUserId: z.string().uuid(), note: z.string().nullable().optional(), privilegeDeletedAt: z.date().nullable().optional(), - status: z.string().default("pending") + status: z.string().default("pending"), + editedByUserId: z.string().uuid().nullable().optional(), + editNote: z.string().nullable().optional() }); export type TAccessApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index fb0728707..afc9e2b73 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({ scannerProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), - maxSharedSecretViewLimit: z.number().nullable().optional() + maxSharedSecretViewLimit: z.number().nullable().optional(), + googleSsoAuthEnforced: z.boolean().default(false), + googleSsoAuthLastUsed: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index ef44344de..d8d3bf9ed 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -3,12 +3,32 @@ import { z } from "zod"; import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { removeTrailingSlash } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; +const maxTimePeriodSchema = z + .string() + .trim() + .nullish() + .transform((val, ctx) => { + if (val === undefined) return undefined; + if (!val || val === "permanent") return null; + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }); + export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -71,7 +91,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + maxTimePeriod: maxTimePeriodSchema }) .refine( (val) => Boolean(val.environment) || Boolean(val.environments), @@ -124,7 +145,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .nullable() .optional(), - bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() + bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array(), + maxTimePeriod: z.string().nullable().optional() }) .array() .nullable() @@ -233,7 +255,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi stepNumber: z.number().int() }) .array() - .optional() + .optional(), + maxTimePeriod: maxTimePeriodSchema }), response: { 200: z.object({ @@ -314,7 +337,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .nullable() - .optional() + .optional(), + maxTimePeriod: z.string().nullable().optional() }) }) } diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7a70d6374..2cfc90564 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas"; import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types"; +import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv body: z.object({ permissions: z.any().array(), isTemporary: z.boolean(), - temporaryRange: z.string().optional(), + temporaryRange: z + .string() + .optional() + .transform((val, ctx) => { + if (!val || val === "permanent") return undefined; + + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }), note: z.string().max(255).optional() }), querystring: z.object({ @@ -116,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string().nullable().optional(), sequence: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(), @@ -128,10 +146,12 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv envId: z.string(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean() + allowedSelfApprovals: z.boolean(), + maxTimePeriod: z.string().nullable().optional() }), reviewers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string(), status: z.string() }) @@ -189,4 +209,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv return { review }; } }); + + server.route({ + url: "/:requestId", + method: "PATCH", + schema: { + params: z.object({ + requestId: z.string().trim() + }), + body: z.object({ + temporaryRange: z.string().transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }), + editNote: z.string().max(255) + }), + response: { + 200: z.object({ + approval: AccessApprovalRequestsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + temporaryRange: req.body.temporaryRange, + editNote: req.body.editNote, + requestId: req.params.requestId + }); + return { approval: request }; + } + }); }; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 3d07af562..104738140 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -294,12 +294,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv 200: z.object({ approval: SecretApprovalRequestsSchema.merge( z.object({ - // secretPath: z.string(), policy: z.object({ id: z.string(), name: z.string(), approvals: z.number(), - approvers: approvalRequestUser.array(), + approvers: approvalRequestUser + .extend({ isOrgMembershipActive: z.boolean().nullable().optional() }) + .array(), bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), @@ -309,7 +310,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), committerUser: approvalRequestUser.nullish(), - reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), + reviewers: approvalRequestUser + .extend({ + status: z.string(), + comment: z.string().optional(), + isOrgMembershipActive: z.boolean().nullable().optional() + }) + .array(), secretPath: z.string(), commits: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 9baf762d6..78221d988 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -56,6 +56,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; projectId: string; bypassers: ( | { @@ -96,6 +97,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environments: { id: string; name: string; @@ -141,6 +143,7 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; } | undefined >; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 0b3c4e128..95d1a9877 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -100,7 +100,8 @@ export const accessApprovalPolicyServiceFactory = ({ environments, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -219,7 +220,8 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + maxTimePeriod }, tx ); @@ -318,7 +320,8 @@ export const accessApprovalPolicyServiceFactory = ({ enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }: TUpdateAccessApprovalPolicy) => { const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); @@ -461,7 +464,8 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + maxTimePeriod }, tx ); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index 27ec228f7..997849800 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -41,6 +41,7 @@ export type TCreateAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string | null; } & Omit; export type TUpdateAccessApprovalPolicy = { @@ -60,6 +61,7 @@ export type TUpdateAccessApprovalPolicy = { allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; environments?: string[]; + maxTimePeriod?: string | null; } & Omit; export type TDeleteAccessApprovalPolicy = { @@ -104,7 +106,8 @@ export interface TAccessApprovalPolicyServiceFactory { environment, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }: TCreateAccessApprovalPolicy) => Promise<{ environment: { name: string; @@ -135,6 +138,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; }>; deleteAccessApprovalPolicy: ({ policyId, @@ -159,6 +163,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; @@ -185,7 +190,8 @@ export interface TAccessApprovalPolicyServiceFactory { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }: TUpdateAccessApprovalPolicy) => Promise<{ environment: { id: string; @@ -208,6 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath?: string | null | undefined; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; }>; getAccessApprovalPolicyByProjectSlug: ({ actorId, @@ -242,6 +249,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; @@ -298,6 +306,7 @@ export interface TAccessApprovalPolicyServiceFactory { allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; + maxTimePeriod?: string | null; environment: { id: string; name: string; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 9872df067..f3972fc1c 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -5,6 +5,7 @@ import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, + TOrgMemberships, TUserGroupMembership, TUsers } from "@app/db/schemas"; @@ -63,6 +64,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `${TableName.Users}.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -297,7 +323,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), - db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") + db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), + + db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), + db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), + db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence")) @@ -364,7 +395,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR enforcementLevel: doc.policyEnforcementLevel, allowedSelfApprovals: doc.policyAllowedSelfApprovals, envId: doc.policyEnvId, - deletedAt: doc.policyDeletedAt + deletedAt: doc.policyDeletedAt, + maxTimePeriod: doc.policyMaxTimePeriod }, requestedByUser: { userId: doc.requestedByUserId, @@ -392,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR { key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) + mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) => + userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined }, { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ + mapper: ({ + approverUserId, + approverSequence, + approvalsRequired, + approverUsername, + approverEmail, + approverIsOrgMembershipActive + }) => ({ userId: approverUserId, sequence: approverSequence, approvalsRequired, email: approverEmail, - username: approverUsername + username: approverUsername, + isOrgMembershipActive: approverIsOrgMembershipActive }) }, { @@ -413,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR approverSequence, approvalsRequired, approverGroupEmail, - approverGroupUsername + approverGroupUsername, + approverGroupIsOrgMembershipActive }) => ({ userId: approverGroupUserId, sequence: approverSequence, approvalsRequired, email: approverGroupEmail, - username: approverGroupUsername + username: approverGroupUsername, + isOrgMembershipActive: approverGroupIsOrgMembershipActive }) }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, @@ -574,7 +617,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), - tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") + tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), + tx.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") ); const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => { @@ -595,7 +639,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, allowedSelfApprovals: el.policyAllowedSelfApprovals, - deletedAt: el.policyDeletedAt + deletedAt: el.policyDeletedAt, + maxTimePeriod: el.policyMaxTimePeriod }, requestedByUser: { userId: el.requestedByUserId, diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index dcbe717da..0f05bd5af 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = { accessApprovalPolicyDAL: Pick; accessApprovalRequestReviewerDAL: Pick< TAccessApprovalRequestReviewerDALFactory, - "create" | "find" | "findOne" | "transaction" + "create" | "find" | "findOne" | "transaction" | "delete" >; groupDAL: Pick; projectMembershipDAL: Pick; @@ -156,6 +156,15 @@ export const accessApprovalRequestServiceFactory = ({ throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); } + // Check if the requested time falls under policy.maxTimePeriod + if (policy.maxTimePeriod) { + if (!temporaryRange || ms(temporaryRange) > ms(policy.maxTimePeriod)) { + throw new BadRequestError({ + message: `Requested access time range is limited to ${policy.maxTimePeriod} by policy` + }); + } + } + const approverIds: string[] = []; const approverGroupIds: string[] = []; @@ -292,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({ return { request: approval }; }; + const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({ + temporaryRange, + actorId, + actor, + actorOrgId, + actorAuthMethod, + editNote, + requestId + }) => { + const cfg = getConfig(); + + const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); + if (!accessApprovalRequest) { + throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` }); + } + + const { policy, requestedByUser } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + + const { membership, hasRole } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: accessApprovalRequest.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.SecretManager + }); + + if (!membership) { + throw new ForbiddenRequestError({ message: "You are not a member of this project" }); + } + + const isApprover = policy.approvers.find((approver) => approver.userId === actorId); + + if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) { + throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" }); + } + + const project = await projectDAL.findById(accessApprovalRequest.projectId); + + if (!project) { + throw new NotFoundError({ + message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]` + }); + } + + if (accessApprovalRequest.status !== ApprovalStatus.PENDING) { + throw new BadRequestError({ message: "The request has been closed" }); + } + + const editedByUser = await userDAL.findById(actorId); + + if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" }); + + if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) { + if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) { + throw new BadRequestError({ message: "Updated access duration must be less than current access duration" }); + } + } + + const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({ + permissions: accessApprovalRequest.permissions + }); + + const approval = await accessApprovalRequestDAL.transaction(async (tx) => { + const approvalRequest = await accessApprovalRequestDAL.updateById( + requestId, + { + temporaryRange, + isTemporary: true, + editNote, + editedByUserId: actorId + }, + tx + ); + + // reset review progress + await accessApprovalRequestReviewerDAL.delete( + { + requestId + }, + tx + ); + + const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; + const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; + const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; + + await triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.ACCESS_REQUEST_UPDATED, + payload: { + projectName: project.name, + requesterFullName, + isTemporary: true, + requesterEmail: requestedByUser.email as string, + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + editNote, + editorEmail: editedByUser.email as string, + editorFullName + } + }, + projectId: project.id + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL + } + }); + + await smtpService.sendMail({ + recipients: policy.approvers + .filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id) + .map((approver) => approver.email!), + subjectLine: "Access Approval Request Updated", + substitutions: { + projectName: project.name, + requesterFullName, + requesterEmail: requestedByUser.email, + isTemporary: true, + expiresIn: msFn(ms(temporaryRange || ""), { long: true }), + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + editNote, + editorFullName, + editorEmail: editedByUser.email + }, + template: SmtpTemplates.AccessApprovalRequestUpdated + }); + + return approvalRequest; + }); + + return { request: approval }; + }; + const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({ projectSlug, authorUserId, @@ -641,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({ return { createAccessApprovalRequest, + updateAccessApprovalRequest, listApprovalRequests, reviewAccessRequest, getCount diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 9066aec8f..f1d3102d6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessApprovalRequestDTO = { + requestId: string; + temporaryRange: string; + editNote: string; +} & Omit; + export type TListApprovalRequestsDTO = { projectSlug: string; authorUserId?: string; @@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory { privilegeDeletedAt?: Date | null | undefined; }; }>; + updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{ + request: { + status: string; + id: string; + createdAt: Date; + updatedAt: Date; + policyId: string; + isTemporary: boolean; + requestedByUserId: string; + privilegeId?: string | null | undefined; + requestedBy?: string | null | undefined; + temporaryRange?: string | null | undefined; + permissions?: unknown; + note?: string | null | undefined; + privilegeDeletedAt?: Date | null | undefined; + }; + }>; listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{ requests: { policy: { @@ -64,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -71,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; @@ -82,6 +107,7 @@ export interface TAccessApprovalRequestServiceFactory { allowedSelfApprovals: boolean; envId: string; deletedAt: Date | null | undefined; + maxTimePeriod?: string | null; }; projectId: string; environment: string; @@ -121,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory { reviewers: { userId: string; status: string; + isOrgMembershipActive: boolean; }[]; approvers: ( | { @@ -129,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -136,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index b58f58164..7a986a2d3 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type"; import { EventType, filterableSecretEvents } from "./audit-log-types"; export interface TAuditLogDALFactory extends Omit, "find"> { - pruneAuditLog: (tx?: knex.Knex) => Promise; + pruneAuditLog: () => Promise; find: ( arg: Omit & { actorId?: string | undefined; @@ -41,6 +41,10 @@ type TFindQuery = { offset?: number; }; +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); @@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => { }; // delete all audit log that have expired - const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - const runPrune = async (dbClient: knex.Knex) => { - const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; - const MAX_RETRY_ON_FAILURE = 3; + const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => { + const today = new Date(); + let deletedAuditLogIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; - const today = new Date(); - let deletedAuditLogIds: { id: string }[] = []; - let numberOfRetryOnFailure = 0; - let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedAuditLogIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); - do { - try { - const findExpiredLogSubQuery = dbClient(TableName.AuditLog) + const findExpiredLogSubQuery = trx(TableName.AuditLog) .where("expiresAt", "<", today) .where("createdAt", "<", today) // to use audit log partition .orderBy(`${TableName.AuditLog}.createdAt`, "desc") @@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => { .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await dbClient(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); - numberOfRetryOnFailure = 0; // reset - } catch (error) { - numberOfRetryOnFailure += 1; - logger.error(error, "Failed to delete audit log on pruning"); - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db - }); - } - isRetrying = numberOfRetryOnFailure > 0; - } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); - }; + const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id"); - if (tx) { - await runPrune(tx); - } else { - const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes - await db.transaction(async (trx) => { - await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - await runPrune(trx); - }); - } + return results; + }); + + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + deletedAuditLogIds = []; + logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; const create: TAuditLogDALFactory["create"] = async (tx) => { diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 9e0f5f998..0914b8f6b 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -1,8 +1,6 @@ import { AxiosError, RawAxiosRequestHeaders } from "axios"; -import { ProjectType, SecretKeyEncoding } from "@app/db/schemas"; -import { TEventBusService } from "@app/ee/services/event/event-bus-service"; -import { TopicName, toPublishableEvent } from "@app/ee/services/event/types"; +import { SecretKeyEncoding } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto/cryptography"; import { logger } from "@app/lib/logger"; @@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = { queueService: TQueueServiceFactory; projectDAL: Pick; licenseService: Pick; - eventBusService: TEventBusService; }; export type TAuditLogQueueServiceFactory = { @@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({ queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }: TAuditLogQueueServiceFactoryDep): Promise => { const pushToLog = async (data: TCreateAuditLogDTO) => { await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, { @@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({ ) ); } - - const publishable = toPublishableEvent(event); - - if (publishable) { - await eventBusService.publish(TopicName.CoreServers, { - type: ProjectType.SecretManager, - source: "infiscal", - data: publishable.data - }); - } }); return { diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 3b405a418..e9fc2b6e5 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -9,7 +9,7 @@ import { getDbConnectionHost } from "@app/lib/knex"; export const verifyHostInputValidity = async (host: string, isGateway = false) => { const appCfg = getConfig(); - if (appCfg.isDevelopmentMode) return [host]; + if (appCfg.isDevelopmentMode || appCfg.isTestMode) return [host]; if (isGateway) return [host]; diff --git a/backend/src/ee/services/dynamic-secret/providers/couchbase.ts b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts new file mode 100644 index 000000000..c59f1c2b3 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts @@ -0,0 +1,289 @@ +import crypto from "node:crypto"; + +import axios from "axios"; +import RE2 from "re2"; + +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url"; + +import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; + +type TCreateCouchbaseUser = { + name: string; + password: string; + access: { + privileges: string[]; + resources: { + buckets: { + name: string; + scopes?: { + name: string; + collections?: string[]; + }[]; + }[]; + }; + }[]; +}; + +type CouchbaseUserResponse = { + id: string; + uuid?: string; +}; + +const sanitizeCouchbaseUsername = (username: string): string => { + // Couchbase username restrictions: + // - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } { + // - Cannot begin with @ character + + const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g"); + let sanitized = forbiddenCharsPattern.replace(username, "-"); + + const leadingAtPattern = new RE2("^@+"); + sanitized = leadingAtPattern.replace(sanitized, ""); + + if (!sanitized || sanitized.length === 0) { + return alphaNumericNanoId(12); + } + + return sanitized; +}; + +/** + * Normalizes bucket configuration to handle wildcard (*) access consistently. + * + * Key behaviors: + * - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections + * + * @param buckets - Either a string or array of bucket configurations + * @returns Normalized bucket resources for Couchbase API + */ +const normalizeBucketConfiguration = ( + buckets: + | string + | Array<{ + name: string; + scopes?: Array<{ + name: string; + collections?: string[]; + }>; + }> +) => { + if (typeof buckets === "string") { + // Simple string format - either "*" or comma-separated bucket names + const bucketNames = buckets + .split(",") + .map((bucket) => bucket.trim()) + .filter((bucket) => bucket.length > 0); + + // If "*" is present anywhere, grant access to all buckets, scopes, and collections + if (bucketNames.includes("*") || buckets === "*") { + return [{ name: "*" }]; + } + return bucketNames.map((bucketName) => ({ name: bucketName })); + } + + // Array of bucket objects with scopes and collections + // Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections + const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*"); + + if (hasWildcardBucket) { + return [{ name: "*" }]; + } + + return buckets.map((bucket) => ({ + name: bucket.name, + scopes: bucket.scopes?.map((scope) => ({ + name: scope.name, + collections: scope.collections || [] + })) + })); +}; + +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(12); + if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername); + + const compiledUsername = compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); + + return sanitizeCouchbaseUsername(compiledUsername); +}; + +const generatePassword = (requirements?: PasswordRequirements): string => { + const { + length = 12, + required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 }, + allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`" + } = requirements || {}; + + const lowercase = "abcdefghijklmnopqrstuvwxyz"; + const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const digits = "0123456789"; + const symbols = allowedSymbols; + + let password = ""; + let remaining = length; + + // Add required characters + for (let i = 0; i < required.lowercase; i += 1) { + password += lowercase[crypto.randomInt(lowercase.length)]; + remaining -= 1; + } + for (let i = 0; i < required.uppercase; i += 1) { + password += uppercase[crypto.randomInt(uppercase.length)]; + remaining -= 1; + } + for (let i = 0; i < required.digits; i += 1) { + password += digits[crypto.randomInt(digits.length)]; + remaining -= 1; + } + for (let i = 0; i < required.symbols; i += 1) { + password += symbols[crypto.randomInt(symbols.length)]; + remaining -= 1; + } + + // Fill remaining with random characters from all sets + const allChars = lowercase + uppercase + digits + symbols; + for (let i = 0; i < remaining; i += 1) { + password += allChars[crypto.randomInt(allChars.length)]; + } + + // Shuffle the password + return password + .split("") + .sort(() => crypto.randomInt(3) - 1) + .join(""); +}; + +const couchbaseApiRequest = async ( + method: string, + url: string, + apiKey: string, + data?: unknown +): Promise => { + await blockLocalAndPrivateIpAddresses(url); + + try { + const response = await axios({ + method: method.toLowerCase() as "get" | "post" | "put" | "delete", + url, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + }, + data: data || undefined, + timeout: 30000 + }); + + return response.data as CouchbaseUserResponse; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [apiKey] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } +}; + +export const CouchbaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: object) => { + const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs); + + await blockLocalAndPrivateIpAddresses(providerInputs.url); + + return providerInputs; + }; + + const validateConnection = async (inputs: unknown): Promise => { + try { + const providerInputs = await validateProviderInputs(inputs as object); + + // Test connection by trying to get organization info + const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`; + await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey); + + return true; + } catch (error) { + throw new BadRequestError({ + message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const create = async ({ + inputs, + usernameTemplate, + identity + }: { + inputs: unknown; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const providerInputs = await validateProviderInputs(inputs as object); + + const username = generateUsername(usernameTemplate, identity); + + const password = generatePassword(providerInputs.passwordRequirements); + + const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`; + + const bucketResources = normalizeBucketConfiguration(providerInputs.buckets); + + const userData: TCreateCouchbaseUser = { + name: username, + password, + access: [ + { + privileges: providerInputs.roles, + resources: { + buckets: bucketResources + } + } + ] + }; + + const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData); + + const userUuid = response?.id || response?.uuid || username; + + return { + entityId: userUuid, + data: { + username, + password + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs as object); + + const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`; + + await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey); + + return { entityId }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // Couchbase Cloud API doesn't support renewing user credentials + // The user remains valid until explicitly deleted + return { 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 7fd65f98d..184b9fc89 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; +import { CouchbaseProvider } from "./couchbase"; import { ElasticSearchProvider } from "./elastic-search"; import { GcpIamProvider } from "./gcp-iam"; import { GithubProvider } from "./github"; @@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.GcpIam]: GcpIamProvider(), - [DynamicSecretProviders.Github]: GithubProvider() + [DynamicSecretProviders.Github]: GithubProvider(), + [DynamicSecretProviders.Couchbase]: CouchbaseProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 528ea414a..ae1bcfc25 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({ .describe("The private key generated for your GitHub App.") }); +export const DynamicSecretCouchbaseSchema = z.object({ + url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"), + orgId: z.string().trim().min(1).describe("Organization ID"), + projectId: z.string().trim().min(1).describe("Project ID"), + clusterId: z.string().trim().min(1).describe("Cluster ID"), + roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"), + buckets: z + .union([ + z + .string() + .trim() + .min(1) + .default("*") + .refine((val) => { + if (val.includes(",")) { + const buckets = val + .split(",") + .map((b) => b.trim()) + .filter((b) => b.length > 0); + if (buckets.includes("*") && buckets.length > 1) { + return false; + } + } + return true; + }, "Cannot combine '*' with other bucket names"), + z + .array( + z.object({ + name: z.string().trim().min(1).describe("Bucket name"), + scopes: z + .array( + z.object({ + name: z.string().trim().min(1).describe("Scope name"), + collections: z.array(z.string().trim().min(1)).optional().describe("Collection names") + }) + ) + .optional() + .describe("Scopes within the bucket") + }) + ) + .refine((buckets) => { + const hasWildcard = buckets.some((bucket) => bucket.name === "*"); + if (hasWildcard && buckets.length > 1) { + return false; + } + return true; + }, "Cannot combine '*' bucket with other buckets") + ]) + .default("*") + .describe( + "Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections" + ), + passwordRequirements: z + .object({ + length: z.number().min(8, "Password must be at least 8 characters").max(128), + required: z + .object({ + lowercase: z.number().min(1, "At least 1 lowercase character required"), + uppercase: z.number().min(1, "At least 1 uppercase character required"), + digits: z.number().min(1, "At least 1 digit required"), + symbols: z.number().min(1, "At least 1 special character required") + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 128; + }, "Sum of required characters cannot exceed 128"), + allowedSymbols: z + .string() + .refine((symbols) => { + const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "ยฃ"]; + return !forbiddenChars.some((char) => symbols?.includes(char)); + }, "Cannot contain: < > ; . * & | ยฃ") + .optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements for Couchbase"), + auth: z.object({ + apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key") + }) +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -524,7 +609,8 @@ export enum DynamicSecretProviders { Kubernetes = "kubernetes", Vertica = "vertica", GcpIam = "gcp-iam", - Github = "github" + Github = "github", + Couchbase = "couchbase" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/event/event-bus-service.ts b/backend/src/ee/services/event/event-bus-service.ts index 63f3b9fae..bb102c721 100644 --- a/backend/src/ee/services/event/event-bus-service.ts +++ b/backend/src/ee/services/event/event-bus-service.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { logger } from "@app/lib/logger"; -import { EventSchema, TopicName } from "./types"; +import { BusEventSchema, TopicName } from "./types"; export const eventBusFactory = (redis: Redis) => { const publisher = redis.duplicate(); @@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => { * @param topic - The topic to publish the event to. * @param event - The event data to publish. */ - const publish = async >(topic: TopicName, event: T) => { + const publish = async >(topic: TopicName, event: T) => { const json = JSON.stringify(event); return publisher.publish(topic, json, (err) => { @@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => { * @template T - The type of the event data, which should match the schema defined in EventSchema. * @returns A function that can be called to unsubscribe from the event bus. */ - const subscribe = >(fn: (data: T) => Promise | void) => { + const subscribe = >(fn: (data: T) => Promise | void) => { // Not using async await cause redis client's `on` method does not expect async listeners. const listener = (channel: string, message: string) => { try { diff --git a/backend/src/ee/services/event/event-sse-service.ts b/backend/src/ee/services/event/event-sse-service.ts index bb85f667c..dc52cc14c 100644 --- a/backend/src/ee/services/event/event-sse-service.ts +++ b/backend/src/ee/services/event/event-sse-service.ts @@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger"; import { TEventBusService } from "./event-bus-service"; import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream"; -import { EventData, RegisteredEvent, toBusEventName } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; const AUTH_REFRESH_INTERVAL = 60 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000; @@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { } }; - function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) { - const eventType = toBusEventName(event.data.eventType); + function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) { + const eventType = event.data.event; const match = registered.find((r) => r.event === eventType); if (!match) return; diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index 258783c2e..13e18374f 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore"; import { conditionsMatcher } from "@app/lib/casl"; import { logger } from "@app/lib/logger"; -import { EventData, RegisteredEvent } from "./types"; +import { BusEvent, RegisteredEvent } from "./types"; export const getServerSentEventsHeaders = () => ({ @@ -55,7 +55,7 @@ export type EventStreamClient = { id: string; stream: Readable; open: () => Promise; - send: (data: EventMessage | EventData) => void; + send: (data: EventMessage | BusEvent) => void; ping: () => Promise; refresh: () => Promise; close: () => void; @@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien return { subject: options.type, action: "subscribe", - conditions: { - eventType: r.event, - ...(hasConditions - ? { - environment: r.conditions?.environmentSlug ?? "", - secretPath: { $glob: secretPath } - } - : {}) - } + conditions: hasConditions + ? { + environment: r.conditions?.environmentSlug ?? "", + secretPath: { $glob: secretPath } + } + : undefined }; }); @@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien // We will manually push data to the stream stream._read = () => {}; - const send = (data: EventMessage | EventData) => { + const send = (data: EventMessage | BusEvent) => { const chunk = serializeSseEvent(data); if (!stream.push(chunk)) { logger.debug("Backpressure detected: dropped manual event"); @@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien await redis.set(key, "1", "EX", 60); - stream.push("1"); + send({ type: "ping" }); }; const close = () => { diff --git a/backend/src/ee/services/event/types.ts b/backend/src/ee/services/event/types.ts index 721e6ea13..5cbaf4c14 100644 --- a/backend/src/ee/services/event/types.ts +++ b/backend/src/ee/services/event/types.ts @@ -1,7 +1,8 @@ import { z } from "zod"; import { ProjectType } from "@app/db/schemas"; -import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types"; + +import { ProjectPermissionSecretEventActions } from "../permission/project-permission"; export enum TopicName { CoreServers = "infisical::core-servers" @@ -10,84 +11,44 @@ export enum TopicName { export enum BusEventName { CreateSecret = "secret:create", UpdateSecret = "secret:update", - DeleteSecret = "secret:delete" + DeleteSecret = "secret:delete", + ImportMutation = "secret:import-mutation" } -type PublisableEventTypes = - | EventType.CREATE_SECRET - | EventType.CREATE_SECRETS - | EventType.DELETE_SECRET - | EventType.DELETE_SECRETS - | EventType.UPDATE_SECRETS - | EventType.UPDATE_SECRET; - -export function toBusEventName(input: EventType) { - switch (input) { - case EventType.CREATE_SECRET: - case EventType.CREATE_SECRETS: - return BusEventName.CreateSecret; - case EventType.UPDATE_SECRET: - case EventType.UPDATE_SECRETS: - return BusEventName.UpdateSecret; - case EventType.DELETE_SECRET: - case EventType.DELETE_SECRETS: - return BusEventName.DeleteSecret; - default: - return null; - } -} - -const isBulkEvent = (event: Event): event is Extract } }> => { - return event.type.endsWith("-secrets"); // Feels so wrong -}; - -export const toPublishableEvent = (event: Event) => { - const name = toBusEventName(event.type); - - if (!name) return null; - - const e = event as Extract; - - if (isBulkEvent(e)) { - return { - name, - isBulk: true, - data: { - eventType: e.type, - payload: e.metadata.secrets.map((s) => ({ - environment: e.metadata.environment, - secretPath: e.metadata.secretPath, - ...s - })) - } - } as const; - } - - return { - name, - isBulk: false, - data: { - eventType: e.type, - payload: { - ...e.metadata, - environment: e.metadata.environment - } +export const Mappings = { + BusEventToAction(input: BusEventName) { + switch (input) { + case BusEventName.CreateSecret: + return ProjectPermissionSecretEventActions.SubscribeCreated; + case BusEventName.DeleteSecret: + return ProjectPermissionSecretEventActions.SubscribeDeleted; + case BusEventName.ImportMutation: + return ProjectPermissionSecretEventActions.SubscribeImportMutations; + case BusEventName.UpdateSecret: + return ProjectPermissionSecretEventActions.SubscribeUpdated; + default: + throw new Error("Unknown bus event name"); } - } as const; + } }; export const EventName = z.nativeEnum(BusEventName); const EventSecretPayload = z.object({ - secretPath: z.string().optional(), secretId: z.string(), + secretPath: z.string().optional(), secretKey: z.string(), environment: z.string() }); +const EventImportMutationPayload = z.object({ + secretPath: z.string(), + environment: z.string() +}); + export type EventSecret = z.infer; -export const EventSchema = z.object({ +export const BusEventSchema = z.object({ datacontenttype: z.literal("application/json").optional().default("application/json"), type: z.nativeEnum(ProjectType), source: z.string(), @@ -95,25 +56,38 @@ export const EventSchema = z.object({ .string() .optional() .default(() => new Date().toISOString()), - data: z.discriminatedUnion("eventType", [ + data: z.discriminatedUnion("event", [ z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]), - payload: EventSecretPayload + event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]), + payload: z.union([EventSecretPayload, EventSecretPayload.array()]) }), z.object({ specversion: z.number().optional().default(1), - eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]), - payload: EventSecretPayload.array() + event: z.enum([BusEventName.ImportMutation]), + payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()]) }) // Add more event types as needed ]) }); -export type EventData = z.infer; +export type BusEvent = z.infer; + +type PublishableEventPayload = z.input["data"]; +type PublishableSecretEvent = Extract< + PublishableEventPayload, + { event: Exclude } +>["payload"]; + +export type PublishableEvent = { + created?: PublishableSecretEvent; + updated?: PublishableSecretEvent; + deleted?: PublishableSecretEvent; + importMutation?: Extract["payload"]; +}; export const EventRegisterSchema = z.object({ - event: EventName, + event: z.nativeEnum(BusEventName), conditions: z .object({ secretPath: z.string().optional().default("/"), diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index 4c42ad7a2..729696baa 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -31,7 +31,7 @@ export const getDefaultOnPremFeatures = () => { caCrl: false, sshHostGroups: false, enterpriseSecretSyncs: false, - enterpriseAppConnections: false, + enterpriseAppConnections: true, machineIdentityAuthTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index bd3949a7e..8d2d6fdbe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + enforceGoogleSSO: false, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 098d00feb..345e26638 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -47,6 +47,7 @@ export type TFeatureSet = { auditLogStreamLimit: 3; githubOrgSync: false; samlSSO: false; + enforceGoogleSSO: false; hsm: false; oidcSSO: false; secretAccessInsights: false; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 11d8e0518..349130d8e 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -13,6 +13,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -161,8 +162,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningConfigs ); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -266,8 +276,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Create, - ProjectPermissionSecretActions.Delete, - ProjectPermissionSecretActions.Subscribe + ProjectPermissionSecretActions.Delete ], ProjectPermissionSub.Secrets ); @@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9677c69b1..cdf55127f 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -35,6 +35,7 @@ export interface TPermissionDALFactory { projectFavorites?: string[] | null | undefined; customRoleSlug?: string | null | undefined; orgAuthEnforced?: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; } & { groups: { id: string; @@ -87,6 +88,7 @@ export interface TPermissionDALFactory { }[]; orgId: string; orgAuthEnforced: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; orgRole: OrgMembershipRole; userId: string; projectId: string; @@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), @@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() @@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), @@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId, username, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole, membershipId, groupMembershipId, @@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) => ({ orgId, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole: orgRole as OrgMembershipRole, userId, projectId, diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index d645e2bec..7d61d7499 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], orgRole: OrgMembershipRole ) { @@ -128,10 +129,16 @@ function validateOrgSSO( throw new UnauthorizedError({ name: "No auth method defined" }); } - if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { return; } + // case: google sso is enforced, but the actor is not using google sso + if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) { + throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" }); + } + + // case: SAML SSO is enforced, but the actor is not using SAML SSO if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 85ee82cca..461ceef46 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -146,6 +146,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, membership.orgAuthEnforced, + membership.orgGoogleSsoAuthEnforced, membership.bypassOrgAuthEnabled, membership.role as OrgMembershipRole ); @@ -238,6 +239,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, userProjectPermission.orgAuthEnforced, + userProjectPermission.orgGoogleSsoAuthEnforced, userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.orgRole ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 899d364eb..ab8fea5df 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions { ReadValue = "readValue", Create = "create", Edit = "edit", - Delete = "delete", - Subscribe = "subscribe" + Delete = "delete" } export enum ProjectPermissionCmekActions { @@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -197,7 +203,8 @@ export enum ProjectPermissionSub { Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -205,7 +212,13 @@ export type SecretSubjectFields = { secretPath: string; secretName?: string; secretTags?: string[]; - eventType?: string; +}; + +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName?: string; + secretTags?: string[]; }; export type SecretFolderSubjectFields = { @@ -344,7 +357,11 @@ export type ProjectPermissionSet = | [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits] | [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ProjectPermissionSub.SecretEvents | (ForcedSubject & SecretEventSubjectFields) + ]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), - + z.object({ + subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretSyncConditionV2Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 49f31bdf6..49512768c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { SecretApprovalRequestsSchema, TableName, + TOrgMemberships, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, TUserGroupMembership, @@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `secretApprovalReviewerUser.id` ) + + .leftJoin( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.SecretApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `secretApprovalPolicyGroupApproverUser.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), @@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), + tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("projectId").withSchema(TableName.Environment), @@ -211,9 +234,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerLastName: lastName, reviewerUsername: username, reviewerFirstName: firstName, - reviewerComment: comment + reviewerComment: comment, + reviewerIsOrgMembershipActive: isOrgMembershipActive }) => - userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined + userId + ? { + userId, + status, + email, + firstName, + lastName, + username, + comment: comment ?? "", + isOrgMembershipActive + } + : undefined }, { key: "approverUserId", @@ -223,13 +258,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverEmail: email, approverUsername: username, approverLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -240,13 +277,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverGroupEmail: email, approverGroupUsername: username, approverGroupLastName: lastName, - approverGroupFirstName: firstName + approverGroupFirstName: firstName, + approverGroupIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { 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 1d1ca0a00..9e32ad7de 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 @@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({ if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); + if (!secretApprovalRequest) throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); @@ -952,13 +953,39 @@ export const secretApprovalRequestServiceFactory = ({ if (!folder) { throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` }); } + + const { secrets } = mergeStatus; + await secretQueueService.syncSecrets({ projectId, orgId: actorOrgId, secretPath: folder.path, environmentSlug: folder.environmentSlug, actorId, - actor + actor, + event: { + created: secrets.created.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + updated: secrets.updated.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })), + deleted: secrets.deleted.map((el) => ({ + environment: folder.environmentSlug, + secretPath: folder.path, + secretId: el.id, + // @ts-expect-error - not present on V1 secrets + secretKey: el.key as string + })) + } }); if (isSoftEnforcement) { @@ -1421,6 +1448,7 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; + const existingTagIds: Record = {}; const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1486,6 +1514,11 @@ export const secretApprovalRequestServiceFactory = ({ type: SecretType.Shared })) ); + + secretsToUpdateStoredInDB.forEach((el) => { + if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id); + }); + if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) throw new NotFoundError({ message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` @@ -1529,7 +1562,10 @@ export const secretApprovalRequestServiceFactory = ({ secretMetadata }) => { const secretId = updatingSecretsGroupByKey[secretKey][0].id; - if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds; + if (tagIds?.length || existingTagIds[secretKey]?.length) { + commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey]; + } + return { ...latestSecretVersions[secretId], secretMetadata, 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 7c0239add..4d8cea6a3 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 @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret"; @@ -13,9 +14,11 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials"; import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret"; import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; +import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; -import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; +import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; import { + TSecretRotationRotateSecretsJobPayload, TSecretRotationV2, TSecretRotationV2GeneratedCredentials, TSecretRotationV2ListItem, @@ -74,6 +77,10 @@ export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rota const appCfg = getConfig(); if (appCfg.isRotationDevelopmentMode) { + if (appCfg.isTestMode) { + // if its test mode, it should always rotate + return new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // Current time + 1 year + } return getNextUTCMinuteInterval(rotateAtUtc); } @@ -263,3 +270,51 @@ export const throwOnImmutableParameterUpdate = ( // do nothing } }; + +export const rotateSecretsFns = async ({ + job, + secretRotationV2DAL, + secretRotationV2Service +}: { + job: { + data: TSecretRotationRotateSecretsJobPayload; + id: string; + retryCount: number; + retryLimit: number; + }; + secretRotationV2DAL: Pick; + secretRotationV2Service: Pick; +}) => { + const { rotationId, queuedAt, isManualRotation } = job.data; + const { retryCount, retryLimit } = job; + + const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + try { + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`); + + if (!secretRotation.isAutoRotationEnabled) { + logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`); + } + + if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) { + // rotated since being queued, skip rotation + logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`); + return; + } + + await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, { + jobId: job.id, + shouldSendNotification: true, + isFinalAttempt: retryCount === retryLimit, + isManualRotation + }); + + logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`); + } catch (error) { + logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`); + throw error; + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts index 765ca3ab3..50c81f444 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts @@ -1,9 +1,12 @@ +import { v4 as uuidv4 } from "uuid"; + import { ProjectMembershipRole } from "@app/db/schemas"; import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; import { getNextUtcRotationInterval, - getSecretRotationRotateSecretJobOptions + getSecretRotationRotateSecretJobOptions, + rotateSecretsFns } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; @@ -63,14 +66,34 @@ export const secretRotationV2QueueServiceFactory = async ({ rotation.lastRotatedAt ).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]` ); - await queueService.queuePg( - QueueJobs.SecretRotationV2RotateSecrets, - { - rotationId: rotation.id, - queuedAt: currentTime - }, - getSecretRotationRotateSecretJobOptions(rotation) - ); + + const data = { + rotationId: rotation.id, + queuedAt: currentTime + } as TSecretRotationRotateSecretsJobPayload; + + if (appCfg.isTestMode) { + logger.warn("secretRotationV2Queue: Manually rotating secrets for test mode"); + await rotateSecretsFns({ + job: { + id: uuidv4(), + data, + retryCount: 0, + retryLimit: 0 + }, + secretRotationV2DAL, + secretRotationV2Service + }); + } else { + await queueService.queuePg( + QueueJobs.SecretRotationV2RotateSecrets, + { + rotationId: rotation.id, + queuedAt: currentTime + }, + getSecretRotationRotateSecretJobOptions(rotation) + ); + } } } catch (error) { logger.error(error, "secretRotationV2Queue: Queue Rotations Error:"); @@ -87,38 +110,14 @@ export const secretRotationV2QueueServiceFactory = async ({ await queueService.startPg( QueueJobs.SecretRotationV2RotateSecrets, async ([job]) => { - const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload; - const { retryCount, retryLimit } = job; - - const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; - - try { - const secretRotation = await secretRotationV2DAL.findById(rotationId); - - if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`); - - if (!secretRotation.isAutoRotationEnabled) { - logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`); - } - - if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) { - // rotated since being queued, skip rotation - logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`); - return; - } - - await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, { - jobId: job.id, - shouldSendNotification: true, - isFinalAttempt: retryCount === retryLimit, - isManualRotation - }); - - logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`); - } catch (error) { - logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`); - throw error; - } + await rotateSecretsFns({ + job: { + ...job, + data: job.data as TSecretRotationRotateSecretsJobPayload + }, + secretRotationV2DAL, + secretRotationV2Service + }); }, { batchSize: 1, diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts index 4e277888a..2cabb7466 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-fns.ts @@ -58,9 +58,9 @@ export function scanDirectory(inputPath: string, outputPath: string, configPath? }); } -export function scanFile(inputPath: string): Promise { +export function scanFile(inputPath: string, configPath?: string): Promise { return new Promise((resolve, reject) => { - const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git`; + const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git ${configPath ? `-c ${configPath}` : ""}`; exec(command, (error) => { if (error && error.code === 77) { reject(error); @@ -166,6 +166,20 @@ export const parseScanErrorMessage = (err: unknown): string => { : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; }; +const generateSecretValuePolicyConfiguration = (entropy: number): string => ` +# Extend default configuration to preserve existing rules +[extend] +useDefault = true + +# Add custom high-entropy rule +[[rules]] +id = "high-entropy" +description = "Will scan for high entropy secrets" +regex = '''.*''' +entropy = ${entropy} +keywords = [] +`; + export const scanSecretPolicyViolations = async ( projectId: string, secretPath: string, @@ -188,14 +202,25 @@ export const scanSecretPolicyViolations = async ( const tempFolder = await createTempFolder(); try { + const configPath = join(tempFolder, "infisical-scan.toml"); + + const secretPolicyConfiguration = generateSecretValuePolicyConfiguration( + appCfg.PARAMS_FOLDER_SECRET_DETECTION_ENTROPY + ); + + await writeTextToFile(configPath, secretPolicyConfiguration); + const scanPromises = secrets .filter((secret) => !ignoreValues.includes(secret.secretValue)) .map(async (secret) => { - const secretFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`); - await writeTextToFile(secretFilePath, `${secret.secretKey}=${secret.secretValue}`); + const secretKeyValueFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`); + const secretValueOnlyFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`); + await writeTextToFile(secretKeyValueFilePath, `${secret.secretKey}=${secret.secretValue}`); + await writeTextToFile(secretValueOnlyFilePath, secret.secretValue); try { - await scanFile(secretFilePath); + await scanFile(secretKeyValueFilePath); + await scanFile(secretValueOnlyFilePath, configPath); } catch (error) { throw new BadRequestError({ message: `Secret value detected in ${secret.secretKey}. Please add this instead to the designated secrets path in the project.`, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cc272f15..884f4e9b5 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2491,6 +2491,7 @@ export const SecretSyncs = { }, RENDER: { serviceId: "The ID of the Render service to sync secrets to.", + environmentGroupId: "The ID of the Render environment group to sync secrets to.", scope: "The Render scope that secrets should be synced to.", type: "The Render resource type to sync secrets to." }, diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 29883a7ad..586a69655 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -79,6 +79,7 @@ const envSchema = z QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All), HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -215,6 +216,7 @@ const envSchema = z return JSON.parse(val) as { secretPath: string; projectId: string }[]; }) ), + PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7), // HSM HSM_LIB_PATH: zpStr(z.string().optional()), @@ -346,7 +348,11 @@ const envSchema = z isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), isDevelopmentMode: data.NODE_ENV === "development", - isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE, + isTestMode: data.NODE_ENV === "test", + isRotationDevelopmentMode: + (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test", + isDailyResourceCleanUpDevelopmentMode: + data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() diff --git a/backend/src/lib/template/dot-access.ts b/backend/src/lib/template/dot-access.ts index ec3208feb..898252acc 100644 --- a/backend/src/lib/template/dot-access.ts +++ b/backend/src/lib/template/dot-access.ts @@ -1,11 +1,11 @@ /** * Safely retrieves a value from a nested object using dot notation path */ -export const getStringValueByDot = ( +export const getValueByDot = ( obj: Record | null | undefined, path: string, - defaultValue?: string -): string | undefined => { + defaultValue?: string | number | boolean +): string | number | boolean | undefined => { // Handle null or undefined input if (!obj) { return defaultValue; @@ -26,7 +26,7 @@ export const getStringValueByDot = ( current = (current as Record)[part]; } - if (typeof current !== "string") { + if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") { return defaultValue; } diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts index 58411bdb0..355761f73 100644 --- a/backend/src/lib/workflow-integrations/trigger-notification.ts +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); if (slackConfig) { - if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if ( + notification.type === TriggerFeature.ACCESS_REQUEST || + notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED + ) { const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { await sendSlackNotification({ @@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl } if (microsoftTeamsConfig) { - if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if ( + notification.type === TriggerFeature.ACCESS_REQUEST || + notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED + ) { if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( microsoftTeamsConfig.accessRequestChannels diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index c18ecb496..f8f55eadd 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack export enum TriggerFeature { SECRET_APPROVAL = "secret-approval", - ACCESS_REQUEST = "access-request" + ACCESS_REQUEST = "access-request", + ACCESS_REQUEST_UPDATED = "access-request-updated" } export type TNotification = @@ -34,6 +35,22 @@ export type TNotification = approvalUrl: string; note?: string; }; + } + | { + type: TriggerFeature.ACCESS_REQUEST_UPDATED; + payload: { + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + projectName: string; + permissions: string[]; + approvalUrl: string; + editNote?: string; + editorFullName?: string; + editorEmail?: string; + }; }; export type TTriggerWorkflowNotificationDTO = { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 82911ff53..099c04084 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -560,8 +560,7 @@ export const registerRoutes = async ( queueService, projectDAL, licenseService, - auditLogStreamDAL, - eventBusService + auditLogStreamDAL }); const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); @@ -1122,7 +1121,9 @@ export const registerRoutes = async ( resourceMetadataDAL, folderCommitService, secretSyncQueue, - reminderService + reminderService, + eventBusService, + licenseService }); const projectService = projectServiceFactory({ @@ -1973,7 +1974,7 @@ export const registerRoutes = async ( await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); - await dailyResourceCleanUp.startCleanUp(); + await dailyResourceCleanUp.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 3f3b58b5e..ea3726c22 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { email: z.string().email().trim(), password: z.string().trim(), firstName: z.string().trim(), - lastName: z.string().trim().optional(), - protectedKey: z.string().trim(), - protectedKeyIV: z.string().trim(), - protectedKeyTag: z.string().trim(), - publicKey: z.string().trim(), - encryptedPrivateKey: z.string().trim(), - encryptedPrivateKeyIV: z.string().trim(), - encryptedPrivateKeyTag: z.string().trim(), - salt: z.string().trim(), - verifier: z.string().trim() + lastName: z.string().trim().optional() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts index 97cff2cd3..bd0202371 100644 --- a/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/render-connection-router.ts @@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider) return services; } }); + + server.route({ + method: "GET", + url: `/:connectionId/environment-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission); + + return groups; + } + }); }; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index a91548285..911979b60 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: () => ({ message: "Authenticated" as const }) }); diff --git a/backend/src/server/routes/v1/event-router.ts b/backend/src/server/routes/v1/event-router.ts index 19dc296bf..156819df9 100644 --- a/backend/src/server/routes/v1/event-router.ts +++ b/backend/src/server/routes/v1/event-router.ts @@ -5,8 +5,8 @@ import { z } from "zod"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream"; -import { EventRegisterSchema } from "@app/ee/services/event/types"; -import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types"; +import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs"; import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => { req.body.register.forEach((r) => { const fields = { environment: r.conditions?.environmentSlug ?? "", - secretPath: r.conditions?.secretPath ?? "/", - eventType: r.event + secretPath: r.conditions?.secretPath ?? "/" }; - const allowed = info.permission.can( - ProjectPermissionSecretActions.Subscribe, - subject(ProjectPermissionSub.Secrets, fields) - ); + const action = Mappings.BusEventToAction(r.event); + + const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields)); if (!allowed) { throw new ForbiddenRequestError({ name: "PermissionDenied", - message: `You are not allowed to subscribe on secrets`, + message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`, details: { - event: fields.eventType, + action, environmentSlug: fields.environment, secretPath: fields.secretPath } diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index c0578fc0a..9acd515c4 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -478,4 +478,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { return { identityMemberships }; } }); + + server.route({ + method: "GET", + url: "/details", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + identityDetails: z.object({ + organization: z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN], { requireOrg: false }), + handler: async (req) => { + const organization = await server.services.org.findIdentityOrganization(req.permission.id); + return { identityDetails: { organization } }; + } + }); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 323354bc1..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), + googleSsoAuthEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7a5a9341f..5e7dce76e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + }) + .merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + .extend({ + isOrgMembershipActive: z.boolean() + }), project: SanitizedProjectSchema.pick({ name: true, id: true }), roles: z.array( z.object({ diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index b307347b8..871259147 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -45,7 +45,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.CREATE.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() @@ -58,7 +58,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - folder: SecretFoldersSchema + folder: SecretFoldersSchema.extend({ + path: z.string() + }) }) } }, @@ -130,7 +132,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.UPDATE.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() @@ -143,7 +145,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - folder: SecretFoldersSchema + folder: SecretFoldersSchema.extend({ + path: z.string() + }) }) } }, @@ -359,7 +363,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .transform(removeTrailingSlash) .describe(FOLDERS.LIST.path) .optional(), - // backward compatiability with cli + // backward compatibility with cli directory: z .string() .trim() diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 0aec39f3e..366fa331d 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { try { // @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); const email = profile?.emails?.[0]?.value; if (!email) @@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort + callbackPort, + orgSlug }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { schema: { querystring: z.object({ callback_port: z.string().optional(), + org_slug: z.string().optional(), is_admin_login: z .string() .optional() @@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (orgSlug) { + req.session.set("orgSlug", orgSlug); + } if (isAdminLogin) { req.session.set("isAdminLogin", isAdminLogin); } diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index b71a744fd..8b9091364 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -283,6 +283,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.Projects], + description: "Get project details by slug", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ slug: slugSchema({ max: 36 }).describe("The slug of the project to get.") }), diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index e4bd2e10d..164aa31d9 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,5 +1,3 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { request } from "@octokit/request"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; import { request as httpRequest } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; @@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async ( ); }; -export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { +export const getGitHubAppAuthToken = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { const appCfg = getConfig(); const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; - const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; + let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; if (!appId || !appPrivateKey) { throw new InternalServerError({ @@ -125,33 +127,65 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => }); } + appPrivateKey = appPrivateKey + .split("\n") + .map((line) => line.trim()) + .join("\n"); + if (appConnection.method !== GitHubConnectionMethod.App) { throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); } - const appAuth = createAppAuth({ - appId, - privateKey: appPrivateKey, - installationId: appConnection.credentials.installationId, - request: request.defaults({ - baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` - }) - }); + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now, + exp: now + 5 * 60, + iss: appId + }; - const { token } = await appAuth({ type: "installation" }); - return token; + const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" }); + + const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection); + const { installationId } = appConnection.credentials; + + const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>( + appConnection, + gatewayService, + { + url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`, + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${appJwt}`, + "X-GitHub-Api-Version": "2022-11-28" + } + } + ); + + return response.data.token; +}; + +const parseGitHubLinkHeader = (linkHeader: string | undefined): Record => { + if (!linkHeader) return {}; + + const links: Record = {}; + const segments = linkHeader.split(","); + const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/); + + for (const segment of segments) { + const match = re.exec(segment.trim()); + if (match) { + const url = match[1]; + const rel = match[2]; + links[rel] = url; + } + } + return links; }; function extractNextPageUrl(linkHeader: string | undefined): string | null { - if (!linkHeader) return null; - - const links = linkHeader.split(","); - const nextLink = links.find((link) => link.includes('rel="next"')); - - if (!nextLink) return null; - - const match = new RE2(/<([^>]+)>/).exec(nextLink); - return match ? match[1] : null; + const links = parseGitHubLinkHeader(linkHeader); + return links.next || null; } export const makePaginatedGitHubRequest = async ( @@ -163,28 +197,86 @@ export const makePaginatedGitHubRequest = async ( const { credentials, method } = appConnection; const token = - method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); - let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + method === GitHubConnectionMethod.OAuth + ? credentials.accessToken + : await getGitHubAppAuthToken(appConnection, gatewayService); + + const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; + const initialUrlObj = new URL(baseUrl); + initialUrlObj.searchParams.set("per_page", "100"); + let results: T[] = []; - let i = 0; + const maxIterations = 1000; - while (url && i < 1000) { - // eslint-disable-next-line no-await-in-loop - const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { - url, - method: "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28" - } - }); + // Make initial request to get link header + const firstResponse: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url: initialUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); - const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); - results = results.concat(items); + const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]); + results = results.concat(firstPageItems); - url = extractNextPageUrl(response.headers.link as string | undefined); - i += 1; + const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined); + const lastPageUrl = linkHeader.last; + + // If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last + if (lastPageUrl) { + const lastPageParam = new URL(lastPageUrl).searchParams.get("page"); + const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1; + + const pageRequests: Promise>[] = []; + + for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) { + const pageUrlObj = new URL(initialUrlObj.toString()); + pageUrlObj.searchParams.set("page", pageNum.toString()); + + pageRequests.push( + requestWithGitHubGateway(appConnection, gatewayService, { + url: pageUrlObj.toString(), + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }) + ); + } + const responses = await Promise.all(pageRequests); + + for (const response of responses) { + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + } + } else { + // Fallback in case last link isn't present + let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined); + let i = 1; + + while (url && i < maxIterations) { + // eslint-disable-next-line no-await-in-loop + const response: AxiosResponse = await requestWithGitHubGateway(appConnection, gatewayService, { + url, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]); + results = results.concat(items); + + url = extractNextPageUrl(response.headers.link as string | undefined); + i += 1; + } } return results; diff --git a/backend/src/services/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts index bf85b9b71..c41c46c50 100644 --- a/backend/src/services/app-connection/render/render-connection-fns.ts +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list import { AppConnection } from "../app-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums"; import { + TRawRenderEnvironmentGroup, TRawRenderService, TRenderConnection, TRenderConnectionConfig, + TRenderEnvironmentGroup, TRenderService } from "./render-connection-types"; @@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom const perPage = 100; let cursor; + let maxIterations = 10; + while (hasMorePages) { + if (maxIterations <= 0) break; + const res: TRawRenderService[] = ( await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { params: new URLSearchParams({ @@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom } else { cursor = res[res.length - 1].cursor; } + + maxIterations -= 1; } return services; @@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect return inputCredentials; }; + +export const listRenderEnvironmentGroups = async ( + appConnection: TRenderConnection +): Promise => { + const { + credentials: { apiKey } + } = appConnection; + + const groups: TRenderEnvironmentGroup[] = []; + let hasMorePages = true; + const perPage = 100; + let cursor; + let maxIterations = 10; + + while (hasMorePages) { + if (maxIterations <= 0) break; + + const res: TRawRenderEnvironmentGroup[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((item) => { + groups.push({ + name: item.envGroup.name, + id: item.envGroup.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + + maxIterations -= 1; + } + + return groups; +}; diff --git a/backend/src/services/app-connection/render/render-connection-service.ts b/backend/src/services/app-connection/render/render-connection-service.ts index 371790bcb..7cf805e9c 100644 --- a/backend/src/services/app-connection/render/render-connection-service.ts +++ b/backend/src/services/app-connection/render/render-connection-service.ts @@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listRenderServices } from "./render-connection-fns"; +import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns"; import { TRenderConnection } from "./render-connection-types"; type TGetAppConnectionFunc = ( @@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc) } }; + const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor); + try { + const groups = await listRenderEnvironmentGroups(appConnection); + + return groups; + } catch (error) { + logger.error(error, "Failed to list environment groups for Render connection"); + return []; + } + }; + return { - listServices + listServices, + listEnvironmentGroups }; }; diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts index 0902472e5..c3922a071 100644 --- a/backend/src/services/app-connection/render/render-connection-types.ts +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -33,3 +33,16 @@ export type TRawRenderService = { name: string; }; }; + +export type TRenderEnvironmentGroup = { + name: string; + id: string; +}; + +export type TRawRenderEnvironmentGroup = { + cursor: string; + envGroup: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 1f986bb72..e3afb754a 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -448,7 +448,9 @@ 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 && org.userStatus !== "invited"); + + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + const selectedOrg = await orgDAL.findById(organizationId); // Check if authEnforced is true, if that's the case, throw an error @@ -458,12 +460,29 @@ export const authLoginServiceFactory = ({ }); } - if (!hasOrganizationMembership) { + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + + if (!canBypass) { + throw new ForbiddenRequestError({ + message: "Google SSO is enforced for this organization. Please use Google SSO to login.", + error: "GoogleSsoEnforced" + }); + } + } + + if (decodedToken.authMethod === AuthMethod.GOOGLE) { + await orgDAL.updateById(selectedOrg.id, { + googleSsoAuthLastUsed: new Date() + }); + } + const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; @@ -509,7 +528,8 @@ export const authLoginServiceFactory = ({ selectedOrg.authEnforced && selectedOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + decodedToken.authMethod !== AuthMethod.GOOGLE ) { await auditLogService.createAuditLog({ orgId: organizationId, @@ -712,7 +732,7 @@ export const authLoginServiceFactory = ({ /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => { // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserByUsername(email); let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; @@ -766,6 +786,8 @@ export const authLoginServiceFactory = ({ const appCfg = getConfig(); + let orgId = ""; + let orgName: undefined | string; if (!user) { // Create a new user based on oAuth if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); @@ -791,7 +813,6 @@ export const authLoginServiceFactory = ({ }); if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { - let orgId = ""; const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); if (!defaultOrg) { throw new BadRequestError({ @@ -831,11 +852,39 @@ export const authLoginServiceFactory = ({ } } + if (!orgId && orgSlug) { + const org = await orgDAL.findOrgBySlug(orgSlug); + + if (org) { + // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org + const orgMembership = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, + [`${TableName.OrgMembership}.isActive` as "isActive"]: true, + [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + }); + + if (orgMembership) { + orgId = org.id; + orgName = org.name; + } + } + } + const isUserCompleted = user.isAccepted; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + + ...(orgId && orgSlug && orgName !== undefined + ? { + organizationId: orgId, + organizationName: orgName, + organizationSlug: orgSlug + } + : {}), + username: user.username, email: user.email, isEmailVerified: user.isEmailVerified, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index d9d9520a8..09d81033f 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -32,6 +32,7 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; + orgSlug?: string; }; export type TOauthTokenExchangeDTO = { diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a776f7245..263838a9d 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { `${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership), @@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }); @@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ isGroupMember: true, id, @@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { id: projectId, name: projectName }, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive }, createdAt }), key: "id", diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 7b0a19414..9cc851437 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -21,7 +21,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({ if (identityJwtAuth.boundClaims) { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({ } // handle both single and multi-valued claims - if ( - !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) - ) { + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new UnauthorizedError({ message: `Access denied: claim mismatch for field ${claimKey}` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts index 7d386afcb..8ed134cba 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts @@ -1,7 +1,16 @@ import picomatch from "picomatch"; -export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => - policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => { if (Array.isArray(fieldValue)) { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 617b21a1f..6585e61f3 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -22,7 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` }); } - filteredClaims[permissionKey] = value; + filteredClaims[permissionKey] = value.toString(); }); } diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts index 38fc99819..e940fda54 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => { }; } + case TriggerFeature.ACCESS_REQUEST_UPDATED: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Updated access approval request pending for review", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ + payload.isTemporary ? "temporary" : "permanent" + } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`, + wrap: true + }, + { + type: "TextBlock", + text: `The following permissions are requested: ${payload.permissions.join(", ")}`, + wrap: true + }, + payload.editNote + ? { + type: "TextBlock", + text: `**Editor Note**: ${payload.editNote}`, + wrap: true + } + : null + ].filter(Boolean), + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: payload.approvalUrl + } + ] + }; + + return { + adaptiveCard + }; + } + default: { throw new BadRequestError({ message: "Teams notification type not supported." diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index b46efc46c..aa22b11c7 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => { } }; + const findIdentityOrganization = async ( + identityId: string + ): Promise<{ id: string; name: string; slug: string; role: string }> => { + try { + const org = await db + .replicaNode()(TableName.IdentityOrgMembership) + .where({ identityId }) + .join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`) + .select(db.ref("id").withSchema(TableName.Organization).as("id")) + .select(db.ref("name").withSchema(TableName.Organization).as("name")) + .select(db.ref("slug").withSchema(TableName.Organization).as("slug")) + .select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role")); + + return org?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "Find identity organization" }); + } + }; + return withTransaction(db, { ...orgOrm, findOrgByProjectId, @@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => { updateMembershipById, deleteMembershipById, deleteMembershipsById, - updateMembership + updateMembership, + findIdentityOrganization }); }; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ae82cd1bc..4a3bdb06e 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ createdAt: true, updatedAt: true, authEnforced: true, + googleSsoAuthEnforced: true, scimEnabled: true, kmsDefaultKeyId: true, defaultMembershipRole: true, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 5779fa7d3..356a8451c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -198,6 +198,15 @@ export const orgServiceFactory = ({ // Filter out orgs where the membership object is an invitation return orgs.filter((org) => org.userStatus !== "invited"); }; + + /* + * Get all organization an identity is part of + * */ + const findIdentityOrganization = async (identityId: string) => { + const org = await orgDAL.findIdentityOrganization(identityId); + + return org; + }; /* * Get all workspace members * */ @@ -355,6 +364,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -421,6 +431,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced !== undefined) { + if (!plan.enforceGoogleSSO) { + throw new BadRequestError({ + message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO." + }); + } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (authEnforced && googleSsoAuthEnforced) { + throw new BadRequestError({ + message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time." + }); + } + if (authEnforced) { const samlCfg = await samlConfigDAL.findOne({ orgId, @@ -451,6 +476,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced) { + if (googleSsoAuthEnforced && currentOrg.authEnforced) { + throw new BadRequestError({ + message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled." + }); + } + + if (!currentOrg.googleSsoAuthLastUsed) { + throw new BadRequestError({ + message: + "Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization." + }); + } + } + let defaultMembershipRole: string | undefined; if (defaultMembershipRoleSlug) { defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ @@ -465,6 +505,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, @@ -1403,6 +1444,7 @@ export const orgServiceFactory = ({ findOrganizationById, findAllOrgMembers, findAllOrganizationOfUser, + findIdentityOrganization, inviteUserToOrganization, verifyUserToOrg, updateOrg, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 645692145..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { name: string; slug: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index 7fbe7343e..bf0bb18af 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({ } } + const envs = await projectEnvDAL.find({ projectId }); + const project = await projectDAL.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) { + // case: limit imposed on number of environments allowed + // case: number of environments used exceeds the number of environments allowed + throw new BadRequestError({ + message: + "Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments." + }); + } + const env = await projectEnvDAL.transaction(async (tx) => { if (position) { const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx); diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 7dfd1f65c..dd503c2a3 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.OrgMembership, (qb) => { + qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn( + `${TableName.OrgMembership}.orgId`, + "=", + `${TableName.Project}.orgId` + ); + }) + .where((qb) => { if (filter.usernames) { void qb.whereIn("username", filter.usernames); @@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }) .orderBy(`${TableName.Users}.username` as "username"); @@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { + email, + username, + firstName, + lastName, + id: userId, + publicKey, + isGhost, + isOrgMembershipActive: isActive + }, project: { id: projectId, name: projectName diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index b9e502922..7cf665141 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({ const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); - // projectMembers[0].project if (includeGroupMembers) { const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const allMembers = [ diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index bfaef5708..dcfa7ea0d 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,5 +1,6 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -41,32 +42,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ serviceTokenService, orgService }: TDailyResourceCleanUpQueueServiceFactoryDep) => { - queueService.start(QueueName.DailyResourceCleanUp, async () => { - logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); - await identityAccessTokenDAL.removeExpiredTokens(); - await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); - await secretSharingDAL.pruneExpiredSharedSecrets(); - await secretSharingDAL.pruneExpiredSecretRequests(); - await snapshotDAL.pruneExcessSnapshots(); - await secretVersionDAL.pruneExcessVersions(); - await secretVersionV2DAL.pruneExcessVersions(); - await secretFolderVersionDAL.pruneExcessVersions(); - await serviceTokenService.notifyExpiringTokens(); - await orgService.notifyInvitedUsers(); - await auditLogDAL.pruneAuditLog(); - logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); - }); + const appCfg = getConfig(); - // we do a repeat cron job in utc timezone at 12 Midnight each day - const startCleanUp = async () => { - // TODO(akhilmhdh): remove later + if (appCfg.isDailyResourceCleanUpDevelopmentMode) { + logger.warn("Daily Resource Clean Up is in development mode."); + } + + const init = async () => { await queueService.stopRepeatableJob( QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, { pattern: "0 0 * * *", utc: true }, QueueName.AuditLogPrune // just a job id ); - // clear previous job await queueService.stopRepeatableJob( QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, @@ -74,18 +62,43 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ QueueName.DailyResourceCleanUp // just a job id ); - await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, { - delay: 5000, - jobId: QueueName.DailyResourceCleanUp, - repeat: { pattern: "0 0 * * *", utc: true } - }); + await queueService.startPg( + QueueJobs.DailyResourceCleanUp, + async () => { + try { + logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + await identityAccessTokenDAL.removeExpiredTokens(); + await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets(); + await secretSharingDAL.pruneExpiredSharedSecrets(); + await secretSharingDAL.pruneExpiredSecretRequests(); + await snapshotDAL.pruneExcessSnapshots(); + await secretVersionDAL.pruneExcessVersions(); + await secretVersionV2DAL.pruneExcessVersions(); + await secretFolderVersionDAL.pruneExcessVersions(); + await serviceTokenService.notifyExpiringTokens(); + await orgService.notifyInvitedUsers(); + await auditLogDAL.pruneAuditLog(); + logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); + } catch (error) { + logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 1 + } + ); + await queueService.schedulePg( + QueueJobs.DailyResourceCleanUp, + appCfg.isDailyResourceCleanUpDevelopmentMode ? "*/5 * * * *" : "0 0 * * *", + undefined, + { tz: "UTC" } + ); }; - queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => { - logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); - }); - return { - startCleanUp + init }; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index a60d29348..90cc25710 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -238,8 +238,16 @@ export const secretFolderServiceFactory = ({ return doc; }); + const [folderWithFullPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]); + + if (!folderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${folder.id}'` + }); + } + await snapshotService.performSnapshot(folder.parentId as string); - return folder; + return { ...folder, path: folderWithFullPath.path }; }; const updateManyFolders = async ({ @@ -496,8 +504,27 @@ export const secretFolderServiceFactory = ({ return doc; }); + const foldersWithFullPaths = await folderDAL.findSecretPathByFolderIds(projectId, [newFolder.id, folder.id]); + + const newFolderWithFullPath = foldersWithFullPaths.find((f) => f?.id === newFolder.id); + if (!newFolderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${newFolder.id}'` + }); + } + + const folderWithFullPath = foldersWithFullPaths.find((f) => f?.id === folder.id); + if (!folderWithFullPath) { + throw new NotFoundError({ + message: `Failed to retrieve path for folder with ID '${folder.id}'` + }); + } + await snapshotService.performSnapshot(newFolder.parentId as string); - return { folder: newFolder, old: folder }; + return { + folder: { ...newFolder, path: newFolderWithFullPath.path }, + old: { ...folder, path: folderWithFullPath.path } + }; }; const $checkFolderPolicy = async ({ diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 403484fc2..30cd93291 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actorId, - actor + actor, + event: { + importMutation: { + secretPath, + environment + } + } }); } @@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({ projectId, environmentSlug: environment, actor, - actorId + actorId, + event: { + importMutation: { + secretPath, + environment + } + } }); await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId); 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 d0e97afa1..680dd4256 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 @@ -1,4 +1,5 @@ import AWS, { AWSError } from "aws-sdk"; +import handlebars from "handlebars"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; @@ -34,18 +35,51 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise => { +const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => { + if (!keySchema || !keySchema.includes("/")) return path; + + if (keySchema.startsWith("/")) { + throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false }); + } + + const keySchemaSegments = handlebars + .compile(keySchema)({ + environment, + secretKey: "{{secretKey}}" + }) + .split("/"); + + const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1); + + if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) { + throw new SecretSyncError({ + message: "Key schema cannot contain '/' after {{secretKey}}", + shouldRetry: false + }); + } + + return `${path}${pathSegments.join("/")}/`; +}; + +const getParametersByPath = async ( + ssm: AWS.SSM, + path: string, + keySchema: string | undefined, + environment: string +): Promise => { const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {}; let hasNext = true; let nextToken: string | undefined; let attempt = 0; + const fullPath = getFullPath({ path, keySchema, environment }); + while (hasNext) { try { // eslint-disable-next-line no-await-in-loop const parameters = await ssm .getParametersByPath({ - Path: path, + Path: fullPath, Recursive: false, WithDecryption: true, MaxResults: BATCH_SIZE, @@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise { if (parameter.Name) { // no leading slash if path is '/' - const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; awsParameterStoreSecretsRecord[secKey] = parameter; } }); @@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise => { +const getParameterMetadataByPath = async ( + ssm: AWS.SSM, + path: string, + keySchema: string | undefined, + environment: string +): Promise => { const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {}; let hasNext = true; let nextToken: string | undefined; let attempt = 0; + const fullPath = getFullPath({ path, keySchema, environment }); + while (hasNext) { try { // eslint-disable-next-line no-await-in-loop @@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise { if (parameter.Name) { // no leading slash if path is '/' - const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; awsParameterStoreMetadataRecord[secKey] = parameter; } }); @@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = { const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); - const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path); + const awsParameterStoreMetadataRecord = await getParameterMetadataByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord( ssm, @@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = { await deleteParametersBatch(ssm, parametersToDelete); }, getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); return Object.fromEntries( Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }]) ); }, removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); + const awsParameterStoreSecretsRecord = await getParametersByPath( + ssm, + destinationConfig.path, + syncOptions.keySchema, + environment!.slug + ); const parametersToDelete: AWS.SSM.Parameter[] = []; 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 e2cf8f6e8..2cae048aa 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -207,7 +207,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const publicKey = await getPublicKey(secretSync, gatewayService, token); @@ -264,7 +264,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); diff --git a/backend/src/services/secret-sync/render/render-sync-enums.ts b/backend/src/services/secret-sync/render/render-sync-enums.ts index dc0af3b91..098f90de0 100644 --- a/backend/src/services/secret-sync/render/render-sync-enums.ts +++ b/backend/src/services/secret-sync/render/render-sync-enums.ts @@ -1,5 +1,6 @@ export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 71347f998..4ab67bdd9 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ -import { isAxiosError } from "axios"; +import { AxiosRequestConfig, isAxiosError } from "axios"; import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { RenderSyncScope } from "./render-sync-enums"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; const MAX_RETRIES = 5; @@ -27,6 +29,80 @@ const makeRequestWithRetry = async (requestFn: () => Promise, attempt = 0) } }; +async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) { + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "GET", + headers: { + Authorization: `Bearer ${input.token}`, + Accept: "application/json" + } + }; + + switch (input.destination.scope) { + case RenderSyncScope.Service: { + req.url = `/services/${input.destination.serviceId}/env-vars`; + + const allSecrets: TRenderSecret[] = []; + let cursor: string | undefined; + + do { + // eslint-disable-next-line @typescript-eslint/no-loop-func + const { data } = await makeRequestWithRetry(() => + request.request< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >({ + ...req, + params: { + cursor + } + }) + ); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + + if (data.length > 0 && data[data.length - 1]?.cursor) { + cursor = data[data.length - 1].cursor; + } else { + cursor = undefined; + } + } while (cursor); + + return allSecrets; + } + case RenderSyncScope.EnvironmentGroup: { + req.url = `/env-groups/${input.destination.environmentGroupId}`; + + const res = await makeRequestWithRetry(() => + request.request<{ + envVars: { + key: string; + value: string; + }[]; + }>(req) + ); + + return res.data.envVars.map((item) => ({ + key: item.key, + value: item.value + })); + } + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +} + const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise => { const { destinationConfig, @@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential } } = secretSync; - const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; - const allSecrets: TRenderSecret[] = []; - let cursor: string | undefined; + const secrets = await getSecrets({ + destination: destinationConfig, + token: apiKey + }); - do { - const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - - const { data } = await makeRequestWithRetry(() => - request.get< - { - envVar: { - key: string; - value: string; - }; - cursor: string; - }[] - >(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } - }) - ); - - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); - - allSecrets.push(...secrets); - - if (data.length > 0 && data[data.length - 1]?.cursor) { - cursor = data[data.length - 1].cursor; - } else { - cursor = undefined; - } - } while (cursor); - - return allSecrets; + return secrets; }; const batchUpdateEnvironmentSecrets = async ( @@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async ( } } = secretSync; - await makeRequestWithRetry(() => - request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "PUT", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars`, + data: envVars + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + for await (const variable of envVars) { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`, + data: { + value: variable.value + } + }) + ); } - }) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +}; + +const deleteEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + envVar: { key: string; value: string } +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "DELETE", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}` + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}` + }) + ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; const redeployService = async (secretSync: TRenderSyncWithCredentials) => { @@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => { } } = secretSync; - await makeRequestWithRetry(() => - request.post( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, - {}, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + method: "POST", + url: `/services/${destinationConfig.serviceId}/deploys`, + data: {} + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + const { data } = await request.request<{ serviceLinks: { id: string }[] }>({ + ...req, + method: "GET", + url: `/env-groups/${destinationConfig.environmentGroupId}` + }); + + for await (const link of data.serviceLinks) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${link.id}/deploys`, + data: {} + }) + ); } - ) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; export const RenderSyncFns = { @@ -169,14 +321,15 @@ export const RenderSyncFns = { const finalEnvVars: Array<{ key: string; value: string }> = []; for (const renderSecret of renderSecrets) { - if (!(renderSecret.key in secretMap)) { + if (renderSecret.key in secretMap) { finalEnvVars.push({ key: renderSecret.key, value: renderSecret.value }); } } - await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el))); if (secretSync.syncOptions.autoRedeployServices) { await redeployService(secretSync); diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts index 0d6e93987..0bc116255 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), + environmentGroupId: z + .string() + .min(1, "Environment Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId), + type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) }) ]); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index c2a72f2e6..8d9c6958a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { throw new BadRequestError({ message: "Missing personal user id" }); } void bd.orWhere({ - key: el.key, - type: el.type, - userId: el.type === SecretType.Personal ? el.userId : null + [`${TableName.SecretV2}.key` as "key"]: el.key, + [`${TableName.SecretV2}.type` as "type"]: el.type, + [`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null }); }); }) @@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .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")) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); - return secrets.map((secret) => ({ - ...secret, - isRotatedSecret: Boolean(secret.rotationId) - })); + + const docs = sqlNestRelationships({ + data: secrets, + key: "id", + parentMapper: (secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs; } catch (error) { throw new DatabaseError({ error, name: "find by secret keys" }); } 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 43daaf0df..101fd9b17 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 @@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({ actorId, actor, projectId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + updated: { + secretId: secret.id, + environment: folder.environment.slug, + secretKey: secret.key, + secretPath + } + } }); } @@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({ actor, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: { + secretId: secretToDelete.id, + environment: folder.environment.slug, + secretKey: secretToDelete.key, + secretPath + } + } }); } @@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + created: newSecrets.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); return newSecrets.map((el) => { @@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath: el.path, projectId, orgId: actorOrgId, - environmentSlug: environment + environmentSlug: environment, + event: { + updated: updatedSecrets.map((sec) => ({ + secretId: sec.id, + secretKey: sec.key, + secretPath: sec.secretPath, + environment + })) + } }) : undefined ) @@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({ secretPath, projectId, orgId: actorOrgId, - environmentSlug: folder.environment.slug + environmentSlug: folder.environment.slug, + event: { + deleted: secretsDeleted.map((el) => ({ + secretId: el.id, + secretKey: el.key, + secretPath, + environment: folder.environment.slug + })) + } }); const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: destinationFolder.path, environmentSlug: destinationFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } @@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({ secretPath: sourceFolder.path, environmentSlug: sourceFolder.environment.slug, actorId, - actor + actor, + event: { + importMutation: { + secretPath: sourceFolder.path, + environment: sourceFolder.environment.slug + } + } }); } diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 689e19673..dc5713901 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -5,6 +5,7 @@ import { Knex } from "knex"; import { ProjectMembershipRole, + ProjectType, ProjectUpgradeStatus, ProjectVersion, SecretType, @@ -12,6 +13,9 @@ import { TSecretVersionsV2 } from "@app/db/schemas"; import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TEventBusService } from "@app/ee/services/event/event-bus-service"; +import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; @@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = { folderCommitService: Pick; secretSyncQueue: Pick; reminderService: Pick; + eventBusService: TEventBusService; + licenseService: Pick; }; export type TGetSecrets = { @@ -172,7 +178,9 @@ export const secretQueueFactory = ({ resourceMetadataDAL, secretSyncQueue, folderCommitService, - reminderService + reminderService, + eventBusService, + licenseService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -534,17 +542,70 @@ export const secretQueueFactory = ({ }); }; + const publishEvents = async (event: PublishableEvent) => { + if (event.created) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.CreateSecret, + payload: event.created + } + }); + } + + if (event.updated) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.UpdateSecret, + payload: event.updated + } + }); + } + + if (event.deleted) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.DeleteSecret, + payload: event.deleted + } + }); + } + + if (event.importMutation) { + await eventBusService.publish(TopicName.CoreServers, { + type: ProjectType.SecretManager, + source: "infiscal", + data: { + event: BusEventName.ImportMutation, + payload: event.importMutation + } + }); + } + }; + const syncSecrets = async ({ // seperate de-dupe queue for integration sync and replication sync _deDupeQueue: deDupeQueue = {}, _depth: depth = 0, _deDupeReplicationQueue: deDupeReplicationQueue = {}, + event, ...dto - }: TSyncSecretsDTO) => { + }: TSyncSecretsDTO & { event?: PublishableEvent }) => { logger.info( `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` ); + const plan = await licenseService.getPlan(dto.orgId); + + if (event && plan.eventSubscriptions) { + await publishEvents(event); + } + const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); if ( !dto.excludeReplication @@ -565,7 +626,7 @@ export const secretQueueFactory = ({ _deDupeQueue: deDupeQueue, _deDupeReplicationQueue: deDupeReplicationQueue, _depth: depth - } as TSyncSecretsDTO, + } as unknown as TSyncSecretsDTO, { removeOnFail: true, removeOnComplete: true, @@ -689,6 +750,7 @@ export const secretQueueFactory = ({ isManual, projectId, secretPath, + depth = 1, deDupeQueue = {} } = job.data as TIntegrationSyncPayload; @@ -738,7 +800,13 @@ export const secretQueueFactory = ({ environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: foldersGroupedById[folderId][0]?.path as string, + environment: foldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); @@ -791,7 +859,13 @@ export const secretQueueFactory = ({ environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, _deDupeQueue: deDupeQueue, _depth: depth + 1, - excludeReplication: true + excludeReplication: true, + event: { + importMutation: { + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string + } + } }) ) ); diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index a111d5372..88db1a84d 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -115,6 +115,44 @@ User Note: ${payload.note}` payloadBlocks }; } + case TriggerFeature.ACCESS_REQUEST_UPDATED: { + const { payload } = notification; + const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ + payload.isTemporary ? "temporary" : "permanent" + } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. + +The following permissions are requested: ${payload.permissions.join(", ")} + +View the request and approve or deny it <${payload.approvalUrl}|here>.${ + payload.editNote + ? ` +Editor Note: ${payload.editNote}` + : "" + }`; + + const payloadBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: "Updated access approval request pending for review", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: messageBody + } + } + ]; + + return { + payloadMessage: messageBody, + payloadBlocks + }; + } default: { throw new BadRequestError({ message: "Slack notification type not supported." diff --git a/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx new file mode 100644 index 000000000..2ade57dac --- /dev/null +++ b/backend/src/services/smtp/emails/AccessApprovalRequestUpdatedTemplate.tsx @@ -0,0 +1,95 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface AccessApprovalRequestUpdatedTemplateProps + extends Omit { + projectName: string; + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + expiresIn: string; + permissions: string[]; + editNote: string; + editorFullName: string; + editorEmail: string; + approvalUrl: string; +} + +export const AccessApprovalRequestUpdatedTemplate = ({ + projectName, + siteUrl, + requesterFullName, + requesterEmail, + isTemporary, + secretPath, + environment, + expiresIn, + permissions, + editNote, + editorEmail, + editorFullName, + approvalUrl +}: AccessApprovalRequestUpdatedTemplateProps) => { + return ( + + + An access approval request was updated and is pending your review for the project {projectName} + +
+ + {editorFullName} ({editorEmail}) has + updated the access request submitted by {requesterFullName} ( + {requesterEmail}) for {secretPath} in + the {environment} environment. + + + {isTemporary && ( + + This access will expire {expiresIn} after approval. + + )} + + The following permissions are requested: + + {permissions.map((permission) => ( + + - {permission} + + ))} + + Editor Note: "{editNote}" + +
+
+ Review Request +
+
+ ); +}; + +export default AccessApprovalRequestUpdatedTemplate; + +AccessApprovalRequestUpdatedTemplate.PreviewProps = { + requesterFullName: "Abigail Williams", + requesterEmail: "abigail@infisical.com", + isTemporary: true, + secretPath: "/api/secrets", + environment: "Production", + siteUrl: "https://infisical.com", + projectName: "Example Project", + expiresIn: "1 day", + permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"], + editNote: "Too permissive, they only need 3 days", + editorEmail: "john@infisical.com", + editorFullName: "John Smith" +} as AccessApprovalRequestUpdatedTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 840a98cad..209cd672b 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -1,4 +1,5 @@ export * from "./AccessApprovalRequestTemplate"; +export * from "./AccessApprovalRequestUpdatedTemplate"; export * from "./EmailMfaTemplate"; export * from "./EmailVerificationTemplate"; export * from "./ExternalImportFailedTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index ac56f0ee4..500d0d89c 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger"; import { AccessApprovalRequestTemplate, + AccessApprovalRequestUpdatedTemplate, EmailMfaTemplate, EmailVerificationTemplate, ExternalImportFailedTemplate, @@ -54,6 +55,7 @@ export enum SmtpTemplates { EmailMfa = "emailMfa", UnlockAccount = "unlockAccount", AccessApprovalRequest = "accessApprovalRequest", + AccessApprovalRequestUpdated = "accessApprovalRequestUpdated", AccessSecretRequestBypassed = "accessSecretRequestBypassed", SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview", // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? @@ -96,6 +98,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate, + [SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate, [SmtpTemplates.EmailVerification]: EmailVerificationTemplate, [SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate, [SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 93ac5cadb..03091a255 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -11,7 +11,6 @@ import { validateOverrides } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; @@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({ return updatedServerCfg; }; - const adminSignUp = async ({ - lastName, - firstName, - email, - salt, - password, - verifier, - publicKey, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - ip, - userAgent - }: TAdminSignUpDTO) => { + const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => { const appCfg = getConfig(); const sanitizedEmail = email.trim().toLowerCase(); const existingUser = await userDAL.findOne({ username: sanitizedEmail }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" }); - const privateKey = await getUserPrivateKey(password, { - encryptionVersion: 2, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - }); - const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); - const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); const userInfo = await userDAL.transaction(async (tx) => { const newUser = await userDAL.create( { @@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({ ); const userEnc = await userDAL.createUserEncryption( { - salt, encryptionVersion: 2, - protectedKey, - protectedKeyIV, - protectedKeyTag, - publicKey, - encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag, - verifier, userId: newUser.id, - hashedPassword, - serverEncryptedPrivateKey: ciphertext, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyEncoding: encoding + hashedPassword }, tx ); + return { user: newUser, enc: userEnc }; }); @@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({ }, tx ); - const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password); - const encKeys = await generateUserSrpKeys(sanitizedEmail, password); + + const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS); const userEnc = await userDAL.createUserEncryption( { userId: newUser.id, encryptionVersion: 2, - protectedKey: encKeys.protectedKey, - protectedKeyIV: encKeys.protectedKeyIV, - protectedKeyTag: encKeys.protectedKeyTag, - publicKey: encKeys.publicKey, - encryptedPrivateKey: encKeys.encryptedPrivateKey, - iv: encKeys.encryptedPrivateKeyIV, - tag: encKeys.encryptedPrivateKeyTag, - salt: encKeys.salt, - verifier: encKeys.verifier, - serverEncryptedPrivateKeyEncoding: encoding, - serverEncryptedPrivateKeyTag: tag, - serverEncryptedPrivateKeyIV: iv, - serverEncryptedPrivateKey: ciphertext + hashedPassword }, tx ); diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index b57a015a4..919b0541c 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env"; export type TAdminSignUpDTO = { email: string; password: string; - publicKey: string; - salt: string; lastName?: string; - verifier: string; firstName: string; - protectedKey: string; - protectedKeyIV: string; - protectedKeyTag: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; ip: string; userAgent: string; }; diff --git a/backend/vitest.e2e.config.ts b/backend/vitest.e2e.config.ts index 684a4dc42..bb7ae8087 100644 --- a/backend/vitest.e2e.config.ts +++ b/backend/vitest.e2e.config.ts @@ -5,7 +5,10 @@ export default defineConfig({ test: { globals: true, env: { - NODE_ENV: "test" + NODE_ENV: "test", + E2E_TEST_ORACLE_DB_19_HOST: process.env.E2E_TEST_ORACLE_DB_19_HOST!, + E2E_TEST_ORACLE_DB_19_USERNAME: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!, + E2E_TEST_ORACLE_DB_19_PASSWORD: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD! }, environment: "./e2e-test/vitest-environment-knex.ts", include: ["./e2e-test/**/*.spec.ts"], diff --git a/docker-compose.e2e-dbs.yml b/docker-compose.e2e-dbs.yml new file mode 100644 index 000000000..41f542207 --- /dev/null +++ b/docker-compose.e2e-dbs.yml @@ -0,0 +1,157 @@ +version: '3.8' + +services: + # Oracle Databases + oracle-db-23.8: + image: container-registry.oracle.com/database/free:23.8.0.0 + container_name: oracle-db-23.8 + ports: + - "1521:1521" + environment: + - ORACLE_PDB=pdb + - ORACLE_PWD=pdb-password + volumes: + - oracle-data-23.8:/opt/oracle/oradata + restart: unless-stopped + healthcheck: + test: ["CMD", "sqlplus", "-L", "system/pdb-password@//localhost:1521/FREEPDB1", "<<<", "SELECT 1 FROM DUAL;"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + # MySQL Databases + mysql-8.4.6: + image: mysql:8.4.6 + container_name: mysql-8.4.6 + ports: + - "3306:3306" + environment: + - MYSQL_ROOT_PASSWORD=mysql-test + - MYSQL_DATABASE=mysql-test + - MYSQL_ROOT_HOST=% + - MYSQL_USER=mysql-test + - MYSQL_PASSWORD=mysql-test + volumes: + - mysql-data-8.4.6:/var/lib/mysql + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + mysql-8.0.29: + image: mysql:8.0.29 + container_name: mysql-8.0.28 + ports: + - "3307:3306" + environment: + - MYSQL_ROOT_PASSWORD=mysql-test + - MYSQL_DATABASE=mysql-test + - MYSQL_ROOT_HOST=% + - MYSQL_USER=mysql-test + - MYSQL_PASSWORD=mysql-test + volumes: + - mysql-data-8.0.29:/var/lib/mysql + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + mysql-5.7.31: + image: mysql:5.7.31 + container_name: mysql-5.7.31 + platform: linux/amd64 + ports: + - "3308:3306" + environment: + - MYSQL_ROOT_PASSWORD=mysql-test + - MYSQL_DATABASE=mysql-test + - MYSQL_ROOT_HOST=% + - MYSQL_USER=mysql-test + - MYSQL_PASSWORD=mysql-test + volumes: + - mysql-data-5.7.31:/var/lib/mysql + restart: unless-stopped + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + + # PostgreSQL Databases + postgres-17: + image: postgres:17 + platform: linux/amd64 + container_name: postgres-17 + ports: + - "5433:5432" + environment: + - POSTGRES_DB=postgres-test + - POSTGRES_USER=postgres-test + - POSTGRES_PASSWORD=postgres-test + volumes: + - postgres-data-17:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + postgres-16: + image: postgres:16 + platform: linux/amd64 + container_name: postgres-16 + ports: + - "5434:5432" + environment: + - POSTGRES_DB=postgres-test + - POSTGRES_USER=postgres-test + - POSTGRES_PASSWORD=postgres-test + volumes: + - postgres-data-16:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + postgres-10.12: + image: postgres:10.12 + platform: linux/amd64 + container_name: postgres-10.12 + ports: + - "5435:5432" + environment: + - POSTGRES_DB=postgres-test + - POSTGRES_USER=postgres-test + - POSTGRES_PASSWORD=postgres-test + volumes: + - postgres-data-10.12:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + +volumes: + oracle-data-23.8: + mysql-data-8.4.6: + mysql-data-8.0.29: + mysql-data-5.7.31: + postgres-data-17: + postgres-data-16: + postgres-data-10.12: \ No newline at end of file diff --git a/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx new file mode 100644 index 000000000..2b9896f8a --- /dev/null +++ b/docs/api-reference/endpoints/workspaces/get-workspace-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Project By Slug" +openapi: "GET /api/v2/workspace/{slug}" +--- diff --git a/docs/docs.json b/docs/docs.json index 960c41727..3b116f4db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,8 +41,6 @@ "group": "Platform Reference", "pages": [ "documentation/platform/organization", - "documentation/platform/event-subscriptions", - "documentation/platform/folder", { "group": "Projects", "pages": [ @@ -145,6 +143,7 @@ } ] }, + "documentation/platform/event-subscriptions", { "group": "Workflow Integrations", "pages": [ @@ -303,6 +302,7 @@ }, "self-hosting/guides/upgrading-infisical", "self-hosting/configuration/envars", + "self-hosting/guides/releases", "self-hosting/configuration/requirements", { "group": "Guides", @@ -310,7 +310,8 @@ "self-hosting/guides/mongo-to-postgres", "self-hosting/guides/custom-certificates", "self-hosting/guides/automated-bootstrapping", - "self-hosting/guides/production-hardening" + "self-hosting/guides/production-hardening", + "self-hosting/guides/monitoring-telemetry" ] }, { @@ -388,8 +389,37 @@ "group": "Secrets Management", "pages": [ "documentation/platform/secrets-mgmt/overview", + { + "group": "Concepts", + "pages": [ + "documentation/platform/secrets-mgmt/concepts/secrets-mgmt", + "documentation/platform/secrets-mgmt/concepts/access-control", + "documentation/platform/secrets-mgmt/concepts/secrets-delivery", + "documentation/platform/secrets-mgmt/concepts/secrets-rotation", + "documentation/platform/secrets-mgmt/concepts/dynamic-secrets" + ] + }, + { + "group": "Guides", + "pages": [ + "documentation/guides/introduction", + "documentation/guides/local-development", + "documentation/guides/node", + "documentation/guides/python", + "documentation/guides/nextjs-vercel", + "documentation/guides/microsoft-power-apps" + ] + } + ] + }, + { + "group": "Product Reference", + "pages": [ "documentation/platform/secrets-mgmt/project", "documentation/platform/folder", + "documentation/platform/secret-versioning", + "documentation/platform/pit-recovery", + "documentation/platform/secret-reference", { "group": "Secret Rotation", "pages": [ @@ -413,6 +443,7 @@ "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/gcp-iam", "documentation/platform/dynamic-secrets/github", @@ -433,17 +464,7 @@ "documentation/platform/dynamic-secrets/vertica" ] }, - { - "group": "Guides", - "pages": [ - "documentation/guides/introduction", - "documentation/guides/local-development", - "documentation/guides/node", - "documentation/guides/python", - "documentation/guides/nextjs-vercel", - "documentation/guides/microsoft-power-apps" - ] - } + "documentation/platform/webhooks" ] }, { @@ -466,7 +487,7 @@ "group": "Agent", "pages": [ "integrations/platforms/infisical-agent", - "integrations/platforms/docker-swarm-with-agent", + "integrations/platforms/docker-swarm-with-agent", "integrations/platforms/ecs-with-agent" ] }, @@ -635,14 +656,21 @@ "item": "Secrets Scanning", "groups": [ { - "group": "Secret Scanning", + "group": "Secrets Scanning", "pages": [ - "documentation/platform/secret-scanning/overview" + "documentation/platform/secret-scanning/overview", + { + "group": "Concepts", + "pages": [ + "documentation/platform/secret-scanning/concepts/secret-scanning" + ] + } ] }, { - "group": "Datasources", + "group": "Product Reference", "pages": [ + "documentation/platform/secret-scanning/usage", "documentation/platform/secret-scanning/bitbucket", "documentation/platform/secret-scanning/github", "documentation/platform/secret-scanning/gitlab" @@ -682,6 +710,18 @@ "group": "Infisical SSH", "pages": [ "documentation/platform/ssh/overview", + { + "group": "Concepts", + "pages": [ + "documentation/platform/ssh/concepts/ssh-certificates" + ] + } + ] + }, + { + "group": "Platform Reference", + "pages": [ + "documentation/platform/ssh/usage", "documentation/platform/ssh/host-groups" ] } @@ -961,6 +1001,7 @@ { "group": "Projects", "pages": [ + "api-reference/endpoints/workspaces/get-workspace-by-slug", "api-reference/endpoints/workspaces/create-workspace", "api-reference/endpoints/workspaces/delete-workspace", "api-reference/endpoints/workspaces/get-workspace", diff --git a/docs/documentation/platform/dynamic-secrets/couchbase.mdx b/docs/documentation/platform/dynamic-secrets/couchbase.mdx new file mode 100644 index 000000000..6a803c1f8 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/couchbase.mdx @@ -0,0 +1,259 @@ +--- +title: "Couchbase" +description: "Learn how to dynamically generate Couchbase Database user credentials." +--- + +The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions. + +## Prerequisite + +Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key). + +The API Key must have permission to manage database users in your Couchbase Cloud organization and project. + +## Set up Dynamic Secrets with Couchbase + + + + 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/couchbase/dynamic-secret-couchbase-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 Couchbase Cloud API URL + + + + Your Couchbase Cloud organization ID + + + + Your Couchbase Cloud project ID + + + + Your Couchbase Cloud cluster ID where users will be created + + + + Database credential roles to assign to the generated user. Available options: + - **read**: Read access to bucket data (alias for data_reader) + - **write**: Read and write access to bucket data (alias for data_writer) + + + + Specify bucket access configuration: + - Use `*` for access to all buckets + - Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets + - Use Advanced Bucket Configuration for granular scope and collection access + + + + Your Couchbase Cloud API Key for authentication + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png) + + + + + ![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png) + + + Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections + + + When Advanced Bucket Configuration is enabled, you can configure: + + + List of buckets with optional scope and collection specifications: + - **Bucket Name**: Name of the bucket (e.g., travel-sample) + - **Scopes**: Optional array of scopes within the bucket + - **Scope Name**: Name of the scope (e.g., inventory, _default) + - **Collections**: Optional array of collection names within the scope + + + + 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}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random 5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + Optional password generation requirements for Couchbase users: + + + Length of the generated password + + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum special characters (default: 1) + + + + Special characters allowed in passwords. Cannot contain: `< > ; . * & | ยฃ` + + + + Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | ยฃ` + + + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png) + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](../../../images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png) + + + + +## Advanced Bucket Configuration Examples + +The advanced bucket configuration allows you to specify granular access control: + +### Example 1: Specific Bucket Access +```json +[ + { + "name": "travel-sample" + } +] +``` + +### Example 2: Bucket with Specific Scopes +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory" + }, + { + "name": "_default" + } + ] + } +] +``` + +### Example 3: Bucket with Scopes and Collections +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory", + "collections": ["airport", "airline"] + }, + { + "name": "_default", + "collections": ["users"] + } + ] + } +] +``` + +## 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. + +![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 + + +## Couchbase Roles and Permissions + +The Couchbase dynamic secret integration supports the following database credential roles: + +- **read**: Provides read-only access to bucket data +- **write**: Provides read and write access to bucket data + + +These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration. + + +## Troubleshooting + +### Common Issues + +1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users +2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key +3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write) +4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible \ No newline at end of file diff --git a/docs/documentation/platform/secret-scanning/concepts/secret-scanning.mdx b/docs/documentation/platform/secret-scanning/concepts/secret-scanning.mdx new file mode 100644 index 000000000..66dcdab19 --- /dev/null +++ b/docs/documentation/platform/secret-scanning/concepts/secret-scanning.mdx @@ -0,0 +1,20 @@ +--- +title: "Secrets Scanning" +description: "Learn what is secret scanning and why it matters for building secure systems." +--- + +## What is Secret Scanning? + +_Secret scanning_ is the process of monitoring code and related systems for exposed secrets โ€” such as API keys, database credentials, and authentication tokens โ€” that may have been accidentally committed or leaked. + +As teams grow and development accelerates, it becomes easy for secrets to slip into version control, CI/CD pipelines, or shared files. Left undetected, secrets can fall into the wrong hands and give attackers direct access to production systems, third-party services, or internal APIs. + +A secret scanning solution helps teams proactively identify and respond to these risks before they result in compromise. Rather than relying on manual review, secret scanning automates detection through pattern matching, entropy analysis, and contextual rules that surface secrets across your infrastructure and repositories. + +## Secret Scanning in Infisical + +Infisical Secret Scanning continuously monitors your source code and connected systems for exposed credentials. It integrates with platforms like [GitHub](/documentation/platform/secret-scanning/github), [GitLab](/documentation/platform/secret-scanning/gitlab), and [Bitbucket](/documentation/platform/secret-scanning/bitbucket) to scan codebases in real-time, detecting leaks as they happen and notifying administrators when action is needed. + +Findings are surfaced with detailed context โ€” including file location, commit metadata, and rule match โ€” and can be tracked through their lifecycle using status labels like `Resolved`, `False Positive`, or `Ignored`. Teams can configure rules, exclusions, and thresholds to reduce noise and tailor detection to their environment. + +In addition to real-time monitoring, Infisical supports both full repository scans and lightweight diff scans, as well as local pre-commit scanning via the [Infisical CLI](/cli/commands/scan). This allows teams to prevent secret leaks before they ever reach production. diff --git a/docs/documentation/platform/secret-scanning/overview.mdx b/docs/documentation/platform/secret-scanning/overview.mdx index 69bf5a783..0ed90cc24 100644 --- a/docs/documentation/platform/secret-scanning/overview.mdx +++ b/docs/documentation/platform/secret-scanning/overview.mdx @@ -1,230 +1,17 @@ --- title: "Secret Scanning" sidebarTitle: "Overview" -description: "Scan and prevent secret leaks in your code repositories" +description: "Learn how to detect and respond to exposed secrets in code." --- -## Introduction +Infisical Secret Scanning helps teams detect leaked credentials โ€” such as API keys, database passwords, and tokens โ€” across source code and developer systems. It allows organizations to proactively catch exposed secrets before they can be exploited, and respond quickly when incidents occur. -Monitor and detect exposed secrets across your data sources, including code repositories, with Infisical Secret Scanning. +Secret Scanning works across both cloud-connected repositories and local developer environments. It integrates with data sources like [GitHub](/documentation/platform/secret-scanning/github), [GitLab](/documentation/platform/secret-scanning/gitlab), and [Bitbucket](/documentation/platform/secret-scanning/bitbucket) to monitor repositories for exposed secrets in real time, and provides a CLI ([`infisical scan`](/cli/commands/scan)) for scanning local directories, Git history, or CI pipelines before changes are pushed. -For additional security, we recommend using our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to check for exposed secrets before pushing your code changes. +Core capabilities include: - - Secret Scanning is a paid feature. - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact team@infisical.com to purchase an enterprise license to use it. - - -## How Secret Scanning Works - -Secret Scanning consists of several components that enable you to quickly respond to secret leaks: - -- **Scanner Engine**: The core component that analyzes your code and detects potential secrets using pattern matching and entropy analysis -- **Real-time Monitoring**: Provides continuous surveillance of your repositories for immediate detection of exposed secrets -- **Alert System**: Notifies organization admins via email when secrets are detected -- **Risk Management**: Allows tracking and managing detected secrets with different status options -- **Data Sources**: Integrates with various data sources and version control systems -- **Customizable Rules**: Supports ignore patterns and custom configurations to reduce false positives - -These components work together to provide comprehensive secret detection and incident response capabilities. - -### Data Sources - -Data sources are configured integrations with external platforms, such as a GitHub organization or a GitLab group, that establish secure connections for scanning purposes using [App Connections](/integrations/app-connections/overview). - -A data source acts as a secure intermediary between the external system and the scanner engine. It manages a collection of scannable resources (such as repositories) and handles the authentication and communication required for scanning operations. - -![data sources](/images/platform/secret-scanning/secret-scanning-data-sources.png) - -### Resources - -Resources are the atomic, scannable units, such as a repository, that can be monitored for secret exposure. Resources are added automatically when a data source is scanned and updated when scanning events are triggered, such as when a user pushes changes to GitHub. - -Each resource maintains its own scanning history and status, allowing for granular monitoring and management of secret scanning across your organization. - -![resources](/images/platform/secret-scanning/secret-scanning-resources.png) - -### Scans - -Scans can be initiated in two ways: - -1. **Full Scan** - Manually triggered scan that comprehensively checks either all resources associated with a data source or a single selected resource. - -2. **Diff Scan** - Automatically executed when **Auto-Scan** is enabled on a data source. This scan type specifically focuses on updates to existing resources. - -All scan activities can be monitored in real-time through the Infisical UI, which displays: -- Current scan status -- Timestamp of the scan -- Resource(s) being scanned -- Detection results (whether any secrets were found) - -![scans](/images/platform/secret-scanning/secret-scanning-scans.png) - -### Findings - -Findings are automatically generated when secret leaks are detected during scanning operations. Each finding contains comprehensive information including: -- The specific scanning rule that identified the leak -- File location and line number where the secret was found -- Resource-specific details (e.g., commit hash and author for Git repositories) - -Findings are initially marked as **Unresolved** and can be updated to one of the following statuses with additional remarks: -- **Resolved** - The issue has been addressed -- **False Positive** - The detection was incorrect -- **Ignore** - The finding can be safely disregarded - -These status options help teams effectively track and manage the lifecycle of detected secret leaks. - -![findings](/images/platform/secret-scanning/secret-scanning-findings.png) - -### Configuration - -You can configure custom scanning rules and exceptions by updating your project's scanning configuration via the UI or API. - -The configuration options allow you to: -- Define custom scanning patterns and rules -- Set up ignore patterns to reduce false positives -- Specify file path exclusions -- Configure entropy thresholds for secret detection -- Add allowlists for known safe patterns - -For detailed configuration options, expand the example configuration below. - - - ```toml - # Title for the configuration file - title = "Some title" - - - # This configuration is the foundation that can be expanded. If there are any overlapping rules - # between this base and the expanded configuration, the rules in this base will take priority. - # Another aspect of extending configurations is the ability to link multiple files, up to a depth of 2. - # "Allowlist" arrays get appended and may have repeated elements. - # "useDefault" and "path" cannot be used simultaneously. Please choose one. - [extend] - # useDefault will extend the base configuration with the default config: - # https://raw.githubusercontent.com/Infisical/infisical/main/cli/config/infisical-scan.toml - useDefault = true - # or you can supply a path to a configuration. Path is relative to where infisical cli - # was invoked, not the location of the base config. - path = "common_config.toml" - - # An array of tables that contain information that define instructions - # on how to detect secrets - [[rules]] - - # Unique identifier for this rule - id = "some-identifier-for-rule" - - # Short human readable description of the rule. - description = "awesome rule 1" - - # Golang regular expression used to detect secrets. Note Golang's regex engine - # does not support lookaheads. - regex = '''one-go-style-regex-for-this-rule''' - - # Golang regular expression used to match paths. This can be used as a standalone rule or it can be used - # in conjunction with a valid `regex` entry. - path = '''a-file-path-regex''' - - # Array of strings used for metadata and reporting purposes. - tags = ["tag","another tag"] - - # A regex match may have many groups, this allows you to specify the group that should be used as (which group the secret is contained in) - # its entropy checked if `entropy` is set. - secretGroup = 3 - - # Float representing the minimum shannon entropy a regex group must have to be considered a secret. - # Shannon entropy measures how random a data is. Since secrets are usually composed of many random characters, they typically have high entropy - entropy = 3.5 - - # Keywords are used for pre-regex check filtering. - # If rule has keywords but the text fragment being scanned doesn't have at least one of it's keywords, it will be skipped for processing further. - # Ideally these values should either be part of the identifier or unique strings specific to the rule's regex - # (introduced in v8.6.0) - keywords = [ - "auth", - "password", - "token", - ] - - # You can include an allowlist table for a single rule to reduce false positives or ignore commits - # with known/rotated secrets - [rules.allowlist] - description = "ignore commit A" - commits = [ "commit-A", "commit-B"] - paths = [ - '''go\.mod''', - '''go\.sum''' - ] - # note: (rule) regexTarget defaults to check the _Secret_ in the finding. - # if regexTarget is not specified then _Secret_ will be used. - # Acceptable values for regexTarget are "match" and "line" - regexTarget = "match" - regexes = [ - '''process''', - '''getenv''', - ] - # note: stopwords targets the extracted secret, not the entire regex match - # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) - stopwords = [ - '''client''', - '''endpoint''', - ] - - - # This is a global allowlist which has a higher order of precedence than rule-specific allowlists. - # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no - # secrets will be detected for said commit. The same logic applies for regexes and paths. - [allowlist] - description = "global allow list" - commits = [ "commit-A", "commit-B", "commit-C"] - paths = [ - '''gitleaks\.toml''', - '''(.*?)(jpg|gif|doc)''' - ] - - # note: (global) regexTarget defaults to check the _Secret_ in the finding. - # if regexTarget is not specified then _Secret_ will be used. - # Acceptable values for regexTarget are "match" and "line" - regexTarget = "match" - - regexes = [ - '''219-09-9999''', - '''078-05-1120''', - '''(9[0-9]{2}|666)-\d{2}-\d{4}''', - ] - # note: stopwords targets the extracted secret, not the entire regex match - # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) - stopwords = [ - '''client''', - '''endpoint''', - ] - ``` - - -![config](/images/platform/secret-scanning/secret-scanning-config.png) - -## Ignoring Known Secrets -If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. - -### infisical-scan:ignore - -To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. - -```js example.js -function helloWorld() { - console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore -} -``` - -### .infisicalignore -An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. -You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. -By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. - -```.ignore .infisicalignore -bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 -bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 -1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 -``` +- Integrated Scanning Across Environments: Monitor secrets in real time across connected repositories like GitHub, GitLab, and Bitbucket, or scan locally using the infisical scan CLI. +- Detection Engine: Identify potential secrets using pattern matching, entropy analysis, and custom rules tailored to your codebase and workflows. +- Flexible Scan Modes: Run full scans manually or configure automatic diff scans triggered by new commits. CLI scans support Git history, file directories, or staged changes in CI pipelines. +- Findings and Lifecycle Management: Track detected secrets with context like file path, commit hash, and scanning rule. Findings can be resolved, ignored, or marked as false positives โ€” with full visibility into scan results over time. +- Custom Configuration and Noise Reduction: Fine-tune scanning behavior with custom patterns, ignore rules (infisical-scan:ignore, .infisicalignore), entropy thresholds, and excluded paths to reduce false positives. diff --git a/docs/documentation/platform/secret-scanning/usage.mdx b/docs/documentation/platform/secret-scanning/usage.mdx new file mode 100644 index 000000000..1bbcc65ed --- /dev/null +++ b/docs/documentation/platform/secret-scanning/usage.mdx @@ -0,0 +1,236 @@ +--- +title: "Usage" +description: "Learn what is secret scanning and why it matters for building secure systems." +--- + +## Introduction + +Monitor and detect exposed secrets across your data sources, including code repositories, with Infisical Secret Scanning. + +For additional security, we recommend using our [CLI Secret Scanner](/cli/scanning-overview#automatically-scan-changes-before-you-commit) to check for exposed secrets before pushing your code changes. + + + Secret Scanning is a paid feature. If you're using Infisical Cloud, then it is + available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license + to use it. + + +## How Secret Scanning Works + +Secret Scanning consists of several components that enable you to quickly respond to secret leaks: + +- **Scanner Engine**: The core component that analyzes your code and detects potential secrets using pattern matching and entropy analysis +- **Real-time Monitoring**: Provides continuous surveillance of your repositories for immediate detection of exposed secrets +- **Alert System**: Notifies organization admins via email when secrets are detected +- **Risk Management**: Allows tracking and managing detected secrets with different status options +- **Data Sources**: Integrates with various data sources and version control systems +- **Customizable Rules**: Supports ignore patterns and custom configurations to reduce false positives + +These components work together to provide comprehensive secret detection and incident response capabilities. + +### Data Sources + +Data sources are configured integrations with external platforms, such as a GitHub organization or a GitLab group, that establish secure connections for scanning purposes using [App Connections](/integrations/app-connections/overview). + +A data source acts as a secure intermediary between the external system and the scanner engine. It manages a collection of scannable resources (such as repositories) and handles the authentication and communication required for scanning operations. + +![data sources](/images/platform/secret-scanning/secret-scanning-data-sources.png) + +### Resources + +Resources are the atomic, scannable units, such as a repository, that can be monitored for secret exposure. Resources are added automatically when a data source is scanned and updated when scanning events are triggered, such as when a user pushes changes to GitHub. + +Each resource maintains its own scanning history and status, allowing for granular monitoring and management of secret scanning across your organization. + +![resources](/images/platform/secret-scanning/secret-scanning-resources.png) + +### Scans + +Scans can be initiated in two ways: + +1. **Full Scan** - Manually triggered scan that comprehensively checks either all resources associated with a data source or a single selected resource. + +2. **Diff Scan** - Automatically executed when **Auto-Scan** is enabled on a data source. This scan type specifically focuses on updates to existing resources. + +All scan activities can be monitored in real-time through the Infisical UI, which displays: + +- Current scan status +- Timestamp of the scan +- Resource(s) being scanned +- Detection results (whether any secrets were found) + +![scans](/images/platform/secret-scanning/secret-scanning-scans.png) + +### Findings + +Findings are automatically generated when secret leaks are detected during scanning operations. Each finding contains comprehensive information including: + +- The specific scanning rule that identified the leak +- File location and line number where the secret was found +- Resource-specific details (e.g., commit hash and author for Git repositories) + +Findings are initially marked as **Unresolved** and can be updated to one of the following statuses with additional remarks: + +- **Resolved** - The issue has been addressed +- **False Positive** - The detection was incorrect +- **Ignore** - The finding can be safely disregarded + +These status options help teams effectively track and manage the lifecycle of detected secret leaks. + +![findings](/images/platform/secret-scanning/secret-scanning-findings.png) + +### Configuration + +You can configure custom scanning rules and exceptions by updating your project's scanning configuration via the UI or API. + +The configuration options allow you to: + +- Define custom scanning patterns and rules +- Set up ignore patterns to reduce false positives +- Specify file path exclusions +- Configure entropy thresholds for secret detection +- Add allowlists for known safe patterns + +For detailed configuration options, expand the example configuration below. + + + ```toml + # Title for the configuration file + title = "Some title" + + # This configuration is the foundation that can be expanded. If there are any overlapping rules + # between this base and the expanded configuration, the rules in this base will take priority. + # Another aspect of extending configurations is the ability to link multiple files, up to a depth of 2. + # "Allowlist" arrays get appended and may have repeated elements. + # "useDefault" and "path" cannot be used simultaneously. Please choose one. + [extend] + # useDefault will extend the base configuration with the default config: + # https://raw.githubusercontent.com/Infisical/infisical/main/cli/config/infisical-scan.toml + useDefault = true + # or you can supply a path to a configuration. Path is relative to where infisical cli + # was invoked, not the location of the base config. + path = "common_config.toml" + + # An array of tables that contain information that define instructions + # on how to detect secrets + [[rules]] + + # Unique identifier for this rule + id = "some-identifier-for-rule" + + # Short human readable description of the rule. + description = "awesome rule 1" + + # Golang regular expression used to detect secrets. Note Golang's regex engine + # does not support lookaheads. + regex = '''one-go-style-regex-for-this-rule''' + + # Golang regular expression used to match paths. This can be used as a standalone rule or it can be used + # in conjunction with a valid `regex` entry. + path = '''a-file-path-regex''' + + # Array of strings used for metadata and reporting purposes. + tags = ["tag","another tag"] + + # A regex match may have many groups, this allows you to specify the group that should be used as (which group the secret is contained in) + # its entropy checked if `entropy` is set. + secretGroup = 3 + + # Float representing the minimum shannon entropy a regex group must have to be considered a secret. + # Shannon entropy measures how random a data is. Since secrets are usually composed of many random characters, they typically have high entropy + entropy = 3.5 + + # Keywords are used for pre-regex check filtering. + # If rule has keywords but the text fragment being scanned doesn't have at least one of it's keywords, it will be skipped for processing further. + # Ideally these values should either be part of the identifier or unique strings specific to the rule's regex + # (introduced in v8.6.0) + keywords = [ + "auth", + "password", + "token", + ] + + # You can include an allowlist table for a single rule to reduce false positives or ignore commits + # with known/rotated secrets + [rules.allowlist] + description = "ignore commit A" + commits = [ "commit-A", "commit-B"] + paths = [ + '''go\.mod''', + '''go\.sum''' + ] + # note: (rule) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + regexes = [ + '''process''', + '''getenv''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + + + # This is a global allowlist which has a higher order of precedence than rule-specific allowlists. + # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no + # secrets will be detected for said commit. The same logic applies for regexes and paths. + [allowlist] + description = "global allow list" + commits = [ "commit-A", "commit-B", "commit-C"] + paths = [ + '''gitleaks\.toml''', + '''(.*?)(jpg|gif|doc)''' + ] + + # note: (global) regexTarget defaults to check the _Secret_ in the finding. + # if regexTarget is not specified then _Secret_ will be used. + # Acceptable values for regexTarget are "match" and "line" + regexTarget = "match" + + regexes = [ + '''219-09-9999''', + '''078-05-1120''', + '''(9[0-9]{2}|666)-\d{2}-\d{4}''', + ] + # note: stopwords targets the extracted secret, not the entire regex match + # if the extracted secret is found in the stopwords list, the finding will be skipped (i.e not included in report) + stopwords = [ + '''client''', + '''endpoint''', + ] + ``` + + + +![config](/images/platform/secret-scanning/secret-scanning-config.png) + +## Ignoring Known Secrets + +If you're intentionally committing a test secret that the secret scanner might flag, you can instruct Infisical to overlook that secret with the methods listed below. + +### infisical-scan:ignore + +To ignore a secret contained in line of code, simply add `infisical-scan:ignore ` at the end of the line as comment in the given programming. + +```js example.js +function helloWorld() { + console.log("8dyfuiRyq=vVc3RRr_edRk-fK__JItpZ"); // infisical-scan:ignore +} +``` + +### .infisicalignore + +An alternative method to exclude specific findings involves creating a .infisicalignore file at your repository's root. +You can then add the fingerprints of the findings you wish to exclude. The [Infisical scan](/cli/scanning-overview) report provides a unique Fingerprint for each secret found. +By incorporating these Fingerprints into the .infisicalignore file, Infisical will skip the corresponding secret findings in subsequent scans. + +```.ignore .infisicalignore +bea0ff6e05a4de73a5db625d4ae181a015b50855:frontend/components/utilities/attemptLogin.js:stripe-access-token:147 +bea0ff6e05a4de73a5db625d4ae181a015b50855:backend/src/json/integrations.json:generic-api-key:5 +1961b92340e5d2613acae528b886c842427ce5d0:frontend/components/utilities/attemptLogin.js:stripe-access-token:148 +``` diff --git a/docs/documentation/platform/secrets-mgmt/concepts/access-control.mdx b/docs/documentation/platform/secrets-mgmt/concepts/access-control.mdx new file mode 100644 index 000000000..fd06a3a11 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/concepts/access-control.mdx @@ -0,0 +1,33 @@ +--- +title: "Scoping Secrets" +description: "Learn how access to secrets is controlled in Infisical." +--- + +## Secret Hierarchy + +Every secret in Infisical is scoped to an environment and a path. + +- An environment separates where secrets are used, such as `development`, `staging`, or `production`. +- A path is an (optional) namespace within an environment that groups related secrets such as `/postgres`, `/redis`, or per-service paths like `/service-a`. + +This structure makes it easy to organize secrets by team, service, or environment, and sets the foundation for controlling who can access what. + +## Access Control + +Access control determines who (or what) can access a secret and under what conditions. Without clear policies, even securely stored secrets can be misused or exposed. + +To control access to secrets, you configure role-based permissions at the project level. These permissions determine which environments and paths a user or machine identity with that role can access. For example, an engineer might have a role that allows them to read secrets in the `development` environment but not those in the `production` environment. + +This model follows the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege) such that each user or machine identity has access only to the secrets it needs โ€” and nothing more. + +## Advanced Capabilities + +Beyond basic role assignments, Infisical includes additional access control mechanisms for more advanced use cases: + +- Access approvals: Users can request access to specific environments or paths. Access can be temporary and reviewed before it is granted, reducing long-term exposure. + +- Secret change approvals: Updates to sensitive secrets can require approval before taking effect. This adds control in environments where unreviewed changes pose risk. + +- Attribute-based access control (ABAC): Permissions can be matched against metadata on a user or machine identity โ€” such as team, service, or environment โ€” enabling dynamic access rules without manual role changes. + +All access and approval actions are logged, so itโ€™s always possible to trace who accessed what, when, and under what conditions. diff --git a/docs/documentation/platform/secrets-mgmt/concepts/dynamic-secrets.mdx b/docs/documentation/platform/secrets-mgmt/concepts/dynamic-secrets.mdx new file mode 100644 index 000000000..c2363db5b --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/concepts/dynamic-secrets.mdx @@ -0,0 +1,22 @@ +--- +title: "Dynamic Secrets" +description: "Learn what dynamic secrets are, why they're useful, and how Infisical enables them." +--- + +## What is a Dynamic Secret? + +A _dynamic secret_ is a time-bound credential generated on demand for a specific user or system. Unlike _static secrets_, which are created and stored ahead of time, or rotated credentials, which are periodically replaced, dynamic secrets donโ€™t exist until theyโ€™re requested โ€” and automatically expire shortly after use. + +Each secret is unique to the identity that requested it, reducing the risk of reuse, long-term exposure, or accidental leaks. Because they are short-lived and tightly scoped, dynamic secrets are well suited for high-security environments, automated systems, and ephemeral workloads where access needs to be both temporary and auditable. + +By limiting the lifespan and visibility of credentials, dynamic secrets offer a strong alternative to managing long-lived secrets manually. + +## Dynamic Secrets in Infisical + +Infisical generates dynamic secrets in real time when a user or machine identity requests access. Each secret is uniquely scoped to the requesting identity, valid just-in-time only for a limited duration, and automatically revoked after it expires. + +Because they are short-lived and identity-specific, dynamic secrets reduce the risk of credential reuse, accidental exposure, or long-term persistence across environments. + +When supported for a given integration, using dynamic secrets is strongly recommended. Infisical currently supports dynamic secret templates for commonly used systems including [PostgreSQL](/documentation/platform/dynamic-secrets/postgresql), [MySQL](/documentation/platform/dynamic-secrets/mysql), [Microsoft SQL Server](/documentation/platform/dynamic-secrets/mssql), [MongoDB Atlas](/documentation/platform/dynamic-secrets/mongo-db), [Redis](/documentation/platform/dynamic-secrets/redis), [AWS IAM](/documentation/platform/dynamic-secrets/aws-iam), [GCP IAM](/documentation/platform/dynamic-secrets/gcp-iam), [Azure Entra ID](/documentation/platform/dynamic-secrets/azure-entra-id), and more. + +To learn more, refer to the [dynamic secrets documentation](/documentation/platform/dynamic-secrets/overview). diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx new file mode 100644 index 000000000..7ad11948c --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx @@ -0,0 +1,96 @@ +--- +title: "Fetching Secrets" +description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them." +--- + +Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them. + +Infisical supports many delivery methods to match a wide range of environments โ€” from [local development](/documentation/platform/secrets-mgmt/concepts/secrets-delivery#local-development%2C-scripts%2C-and-one-off-tasks) to [Kubernetes workloads](/documentation/platform/secrets-mgmt/concepts/secrets-delivery#kubernetes-workloads), [CI/CD pipelines](/documentation/platform/secrets-mgmt/concepts/secrets-delivery#ci%2Fcd-pipelines), [infrastructure-as-code tools](/documentation/platform/secrets-mgmt/concepts/secrets-delivery#infrastructure-as-code-and-automation-tools), and more. + +The table below provides a quick overview of which delivery method may be suitable to use based on your environment and how secrets are consumed: + +| Use Case / Environment | Recommended Method(s) | Consumes Secrets As | Notes | +| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------ | +| Local development or scripting | [Infisical CLI](/cli/overview) | Environment variables | Easiest way to inject secrets during local dev or debugging | +| Application code fetching at runtime | [SDKs](/sdks/overview), [HTTP API](/api-reference/overview/introduction) | In-memory / API call | Full control in app code; supports dynamic or ephemeral fetching | +| VMs, containers, or CI jobs needing preloaded secrets | [Infisical Agent](/integrations/platforms/infisical-agent) | Env vars or files | Good for non-interactive workloads; avoids inline secret fetch | +| GitHub Actions | [Secrets Action](https://github.com/Infisical/secrets-action), [Secret Syncs](/integrations/secret-syncs/github) | Env vars or files | Use Action for dynamic fetch; use Syncs to preload into GitHub | +| GitLab CI, Jenkins, other CI | [Infisical CLI](/cli/overview), [Infisical Agent](/integrations/platforms/infisical-agent), [Secret Syncs](/integrations/secret-syncs/gitlab) | Env vars or files | Choose based on timing โ€” fetch at runtime vs. pre-populate ahead | +| Kubernetes (declarative secrets) | [Kubernetes Operator](/integrations/platforms/kubernetes/overview) | Kubernetes Secrets | Syncs from Infisical into native Kubernetes Secrets | +| Kubernetes (ESO-based workflows) | [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical/) | Kubernetes Secrets | Reuses existing ESO setup; Infisical acts as a provider | +| Kubernetes (file-based, no K8s secrets) | [Kubernetes Agent Injector](/integrations/platforms/kubernetes-injector) | Mounted files | Injects secrets via init container into volume at pod startup | +| Kubernetes (file-based, with rotation) | [Kubernetes CSI Provider](/integrations/platforms/kubernetes-csi) | Mounted files | Uses CSI driver to mount secrets as files with automatic rotation | +| Image builds (VMs or containers) | [Packer Plugin](/integrations/frameworks/packer) | Env vars or files | Inject secrets at image build time | +| Ansible automation | [Ansible Collection](/integrations/platforms/ansible) | Variables | Runtime secret fetching in playbooks using lookup plugin | +| Terraform / Pulumi | [Terraform Provider](/integrations/frameworks/terraform), [Pulumi](/integrations/frameworks/pulumi) | Inputs / ephemeral resources | Use ephemeral for security; avoids storing secrets in state | +| Third-party platforms (GitHub, AWS, etc.) | [Secret Syncs](/integrations/secret-syncs/overview) | Preloaded secrets | Push secrets to platforms that can't fetch directly from Infisical | + +From here, you can explore the delivery method that best matches your environment: + +## Local Development, Scripts, and One-Off Tasks + +For local development, one-off scripts, or basic automation, the [Infisical CLI](/cli/overview) is a quick and flexible option. + +Instead of using a `.env` file, you can use [`infisical run`](/cli/commands/run) to inject secrets as environment variables directly into your development process. This provides a cleaner and more secure workflow. You can also use [`infisical secrets`](/cli/commands/secrets#infisical-secrets) to perform CRUD operations on secrets from the command line, which works well for debugging, local tooling, and lightweight scripting. + +To learn more, refer to the [CLI quickstart](cli/usage). + +## Applications and Services + +When secrets need to be accessed directly from within application code, Infisical provides SDKs for [Node.js](https://github.com/Infisical/node-sdk-v2), [Python](https://github.com/Infisical/python-sdk-official), [Go](/sdks/languages/go), [Java](https://github.com/Infisical/java-sdk), [.NET](https://github.com/Infisical/infisical-dotnet-sdk), [C++](https://github.com/Infisical/infisical-cpp-sdk), [Rust](https://github.com/Infisical/rust-sdk), and [Ruby](/sdks/languages/ruby). + +These SDKs let services fetch secrets at runtime or startup. For unsupported languages or if you prefer direct integration, you can use the fully documented [HTTP API](/api-reference/overview/introduction) to fetch secrets within your application logic. + +This approach gives you fine-grained control but also requires managing authentication and caching. + +## Virtual Machines(VMs), Containers, and CI Environments + +For systems that shouldnโ€™t fetch secrets themselves โ€” such as production VMs, Docker containers, or CI jobs โ€” the [Infisical Agent](/integrations/platforms/infisical-agent) can be used to sync secrets into the local environment on their behalf. + +The agent runs as a lightweight background process and supports injecting secrets into files, environment variables, or application config formats. It's especially useful for non-interactive workloads that expect secrets to already exist at runtime. + +You can run the agent as a standalone binary, as a Docker sidecar, or embedded in automation scripts. It works well in environments like: + +- VMs that need secrets provisioned at startup. +- [Docker Swarm](/integrations/platforms/docker-swarm-with-agent) services using shared volumes. +- [ECS tasks](/integrations/platforms/ecs-with-agent) using EFS for shared secret delivery. + +## CI/CD Pipelines + +For CI/CD pipelines, the right method depends on the platform. + +- On GitHub Actions, the [Infisical Secrets Action](https://github.com/Infisical/secrets-action) provides a native integration that injects secrets as environment variables or `.env` files during workflows. It supports authentication via [AWS IAM](/documentation/platform/identities/aws-auth), [OIDC](/documentation/platform/identities/oidc-auth/github), or [Universal Auth](/documentation/platform/identities/universal-auth) using a Machine Identity. +- On other CI platforms like GitLab CI, CircleCI, or Jenkins, the CLI or Agent may be used depending on how secrets are consumed โ€” whether at runtime or during setup. + +Some CI/CD systems also support [Secret Syncs](/integrations/secret-syncs/overview) as an alternative. Instead of fetching secrets dynamically, you can configure Infisical to forward secrets into [GitHub Actions](/integrations/secret-syncs/github), [GitLab CI](/integrations/secret-syncs/gitlab), and similar platforms ahead of time โ€” allowing them to be used as native environment secrets during jobs. + +## Kubernetes Workloads + +Infisical supports multiple options for delivering secrets into Kubernetes, each designed to match different operational models and consumption patterns: + +- [Infisical Kubernetes Operator](/integrations/platforms/kubernetes/overview): A set of CRDs that sync secrets from Infisical into Kubernetes Secrets, push secrets from Kubernetes back to Infisical, and manage dynamic secrets with automatic leases. Best suited for teams using declarative workflows and wanting to treat secrets as part of infrastructure code. + +- [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical/): Enables syncing Infisical secrets into Kubernetes by defining ExternalSecret resources. Ideal if your team already uses ESO as a centralized way to fetch secrets from multiple providers. + +- [Infisical Agent Injector](/integrations/platforms/kubernetes-injector): A Kubernetes mutating admission webhook that injects an init container into your pods. The injected container syncs secrets from Infisical into a shared volume at startup, making them available as files. Useful for workloads that expect file-based secrets and where avoiding Kubernetes Secrets entirely is preferred. + +- [Infisical CSI Provider](/integrations/platforms/kubernetes-csi): Integrates with the Kubernetes Secrets Store CSI Driver to mount secrets as files in pods. Supports automatic rotation and fine-grained control over how secrets are structured and updated. Suitable for environments that require file-based secret delivery with rotation, without persisting Kubernetes Secrets. + +These methods provide secure, declarative integrations with Kubernetes-native workflows. + +## Forwarding to Third-Party Platforms + +In some cases, secrets must be delivered into systems that canโ€™t fetch them from Infisical directly. + +[Secret Syncs](/integrations/secret-syncs/overview) allow you to forward secrets to platforms such as [GitHub](/integrations/secret-syncs/github), [GitLab](/integrations/secret-syncs/gitlab), [AWS Secrets Manager](/integrations/secret-syncs/aws-secrets-manager), [Vercel](/integrations/secret-syncs/vercel), and more. + +This is useful when external systems require secrets to be available ahead of time or expect them in a specific location. + +## Infrastructure-as-Code and Automation Tools + +Infisical integrates with common IaC and automation tools to help you securely inject secrets into your infrastructure provisioning workflows: + +- [Terraform](/integrations/frameworks/terraform): Use the official Infisical Terraform provider to fetch secrets either as ephemeral resources (never written to state files) or as traditional data sources. Ideal for managing cloud infrastructure while keeping secrets secure and version-safe. +- [Pulumi](/integrations/frameworks/pulumi): Integrate Infisical into Pulumi projects using the Terraform Bridge, allowing you to fetch and manage secrets in TypeScript, Go, Python, or C# โ€” without changing your existing workflows. +- [Ansible](/integrations/platforms/ansible): Retrieve secrets from Infisical at runtime using the official Ansible Collection and lookup plugin. Works well for dynamic configuration during playbook execution. +- [Packer](/integrations/frameworks/packer): Inject secrets into VM or container images at build time using the Infisical Packer Plugin โ€” useful for provisioning base images that require secure configuration values. diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx new file mode 100644 index 000000000..9fe68fb07 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx @@ -0,0 +1,18 @@ +--- +title: "Secrets Management" +description: "Learn what is secrets management and why it matters for building secure systems." +--- + +## What is Secret? + +A _secret_ is a confidential value used by an application such as database credential, API key, or other configuration. + +In most cases, secrets allow applications to access systems and control how they behave across the development cycle โ€” for example, an application might use a database password stored in an environment variable like `DB_PASSWORD` to connect to production data. These secrets must be kept secure to protect infrastructure and data. + +## What is Secrets Management? + +As infrastructure scales and systems become more distributed, [secrets sprawl](https://infisical.com/blog/what-is-secret-sprawl). Without consistent security practices, secrets get hardcoded in source code, exposed in environment variables, left unrotated for long periods, and scattered across systems without clear visibility into who can access them. + +To solve secret sprawl, organizations rely on [secrets management](https://infisical.com/blog/what-is-secrets-management): the practice of centralizing secrets and managing them through well-defined workflows. This includes secure storage, fine-grained access controls, automatic rotation, audit logging, and support for dynamic, short-lived credentials. + +A consistent approach makes it easier to keep secrets safe, reduce risk, and operate reliably across environments. diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-rotation.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-rotation.mdx new file mode 100644 index 000000000..bb03a7515 --- /dev/null +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-rotation.mdx @@ -0,0 +1,18 @@ +--- +title: "Secrets Rotation" +description: "Learn what secrets rotation is, why it matters, and how Infisical enables it." +--- + +## What is Secrets Rotation? + +Secrets rotation is the process of regularly replacing credentials like API keys, database passwords, and tokens to reduce the risk of long-term exposure. Even if a secret is compromised, frequent rotation limits how long it can be used. + +Without rotation, secrets often go unchanged for months or years โ€” hardcoded in codebases, embedded in CI pipelines, or shared across environments. Over time, this increases the risk of leaks, misuse, and operational blind spots. + +## Secrets Rotation in Infisical + +Infisical automates rotation using a rolling lifecycle model where new credentials are issued on a fixed schedule with previous ones remaining temporarily valid to give systems time to update without disruption. Each secret moves through three phases: active, inactive, and eventually revoked. This ensures that applications continue to function smoothly throughout the rotation process. + +When rotation is applicable for a given secret type, using it is strongly recommended. Infisical supports configuring automatic rotation for a growing set of use cases including [PostgreSQL](/documentation/platform/secret-rotation/postgres-credentials), [MySQL](/documentation/platform/secret-rotation/mysql-credentials), [Microsoft SQL Server](/documentation/platform/secret-rotation/mssql-credentials), [OracleDB](/documentation/platform/secret-rotation/oracledb-credentials), [LDAP](/documentation/platform/secret-rotation/ldap-password), [AWS IAM users](/documentation/platform/secret-rotation/aws-iam-user-secret), [Azure](/documentation/platform/secret-rotation/azure-client-secret) and [Okta](/documentation/platform/secret-rotation/okta-client-secret) client secrets, and more. + +To learn more, refer to the [secrets rotation documentation](/documentation/platform/secret-rotation/overview). diff --git a/docs/documentation/platform/secrets-mgmt/overview.mdx b/docs/documentation/platform/secrets-mgmt/overview.mdx index e00eba358..c1905d629 100644 --- a/docs/documentation/platform/secrets-mgmt/overview.mdx +++ b/docs/documentation/platform/secrets-mgmt/overview.mdx @@ -12,6 +12,6 @@ Core capabilities include: - Secret Stores: Secure, versioned storage scoped by [project](/documentation/platform/secrets-mgmt/project), [environment](/documentation/platform/secrets-mgmt/project#project-environments), and [path](/documentation/platform/folder). - [Access Control](/documentation/platform/access-controls/overview): Fine-grained, identity-aware permissions for users and machines -- Secret Delivery: Access secrets via [CLI](/cli/overview), [SDKs](/sdks/overview) (Go, Node.js, Python, etc.), [HTTP API](/api-reference/overview/introduction), [agents](/integrations/platforms/infisical-agent), [Kubernetes Operator](/integrations/platforms/kubernetes/overview), [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical), and more. -- Lifecycle Automation: Automate [secret rotation](/documentation/platform/secret-rotation/overview), generate [dynamic secrets](/documentation/platform/dynamic-secrets/overview), and enforce [approval-based workflows](/documentation/platform/pr-workflows). +- [Secret Delivery](/documentation/platform/secrets-mgmt/concepts/secrets-delivery): Access secrets via [CLI](/cli/overview), [SDKs](/sdks/overview) (Go, Node.js, Python, etc.), [HTTP API](/api-reference/overview/introduction), [agents](/integrations/platforms/infisical-agent), [Kubernetes Operator](/integrations/platforms/kubernetes/overview), [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical), and more. +- Lifecycle Automation: Automate [secret rotation](/documentation/platform/secrets-mgmt/concepts/secrets-rotation), generate [dynamic secrets](/documentation/platform/secrets-mgmt/concepts/dynamic-secrets), and enforce [approval-based workflows](/documentation/platform/pr-workflows). - [Secrets Syncs](/integrations/secret-syncs/overview): Push secrets to external services like [GitHub](/integrations/secret-syncs/github), [GitLab](/integrations/secret-syncs/gitlab), [AWS Secrets Manager](/integrations/secret-syncs/aws-secrets-manager), [Vercel](/integrations/secret-syncs/vercel), and more. diff --git a/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx new file mode 100644 index 000000000..74bfca228 --- /dev/null +++ b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx @@ -0,0 +1,26 @@ +--- +title: "SSH Certificates" +description: "Learn what SSH certificates are, why they're useful, and how they enable secure, scalable infrastructure access." +--- + +SSH access is ubiquitous โ€” It's how engineers, scripts, and platforms across the world remotely administer Linux systems. That said, as teams and systems grow, managing access with static SSH keys becomes brittle and issues like key sprawl, unclear boundaries, and poor revocation hygiene start to emerge. + +_SSH certificates_ offer an alternative approach to securing and managing access at scale. + +## What is an SSH Certificate? + +An _SSH certificate_ is a short-lived, signed credential that proves a user or hostโ€™s identity. Unlike static SSH keys, which are distributed and managed manually, SSH certificates rely on a centralized certificate authority (CA) to vouch for identities. +There are two types of SSH certificates: + +- User certificates: Issued to users to authenticate with remote hosts +- Host certificates: Issued to hosts so clients can verify they're trusted + +Because certificates are time-bound and centrally managed, theyโ€™re easier to audit, revoke, and scale across infrastructure. + +## SSH with Infisical + +Infisical SSH gives you a secure, scalable way to manage infrastructure access using SSH certificates โ€” without the overhead of running your own certificate authority, wiring trust across hosts, or building issuance workflows from scratch. + +It replaces long-lived SSH keys with short-lived, identity-bound certificates and handles all the moving parts for you: operating CAs, configuring trust between users and hosts, and issuing certificates on demand. With Infisical SSH, you can register a host with [`infisical ssh add-host`](/docs/cli/commands/ssh#infisical-ssh-add-host), then connect with [`infisical ssh connect`](/docs/cli/commands/ssh#infisical-ssh-connect) โ€” thatโ€™s all it takes. + +The result is centralized, auditable SSH access thatโ€™s easy to use and built to scale with your infrastructure. diff --git a/docs/documentation/platform/ssh/overview.mdx b/docs/documentation/platform/ssh/overview.mdx index d9606214d..9de14478e 100644 --- a/docs/documentation/platform/ssh/overview.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,201 +1,15 @@ --- -title: "Overview" +title: "Infisical SSH" sidebarTitle: "Overview" -description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." +description: "Learn how to manage secure, short-lived SSH access to infrastructure using certificates." --- -## Concept +Infisical SSH provides a secure, certificate-based solution for managing SSH access to infrastructure. +It replaces long-lived SSH keys with short-lived, identity-bound certificates to reduce key sprawl, simplify access control, and improve auditability. -Infisical SSH can be configured to provide users on your team short-lived, secure SSH access to infrastructure. Under the hood, it uses SSH certificates -and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, -unauthorized access, and SSH key sprawl. +Core capabilities include: -The following entities are important to understand when configuring and using Infisical SSH: - -- Administrator: An individual on your team who is responsible for configuring Infisical SSH. -- Users: Other individuals that gain access to remote hosts through Infisical SSH. -- Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. - -## Workflow - -The typical workflow for using Infisical SSH consists of the following steps: - -1. The administrator registers a remote host with Infisical using the Infisical CLI via the `infisical ssh add-host` command. -2. The administrator configures Infisical SSH to grant users access to the remote host. -3. User(s) access the remote host using the Infisical CLI via the `infisical ssh connect` command. - -## Admin Guide for Configuring Infisical SSH - -In the following steps, we explore how to configure Infisical SSH to control and streamline your team's SSH access to infrastructure. As part of this guide, -we will register a remote host with Infisical through a [machine identity](/documentation/platform/identities/machine-identities) and configure Infisical to grant user(s) access to the remote host. - - - - Start by creating a new Infisical SSH project in Infisical. - - ![ssh project create](/images/platform/ssh/v2/ssh-create-project.png) - - - 2.1. Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - - By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the identity to authenticate with Infisical - as part of registering a remote host in step 3. - - - You may use other authentication methods as suitable (e.g. [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), [GCP Auth](/documentation/platform/identities/gcp-auth), etc.) as part of the machine identity configuration but, to keep this example simple, we will be using Universal Auth. - - - 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the **SSH Host Bootstrapper** role. - - This role grants the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical - and establish the necessary configuration on it. - - - If you plan to use a custom role to bootstrap SSH hosts, ensure the role has the **Create** and **Issue Host Certificates** on the **SSH Host** resource. - - - ![ssh add identity to project](/images/platform/ssh/v2/ssh-add-identity-to-project.png) - - - - 3.1. Follow the instructions [here](/cli/overview) to install the Infisical CLI onto the remote host. - - 3.2. Run the commands below to register the remote host with Infisical. - - Use the **Client ID** and **Client Secret** from the machine identity you created in step 2.1 as part of the `infisical login` command - to obtain an access token and save it as an environment variable. - - ```bash - export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) - ``` - - Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. - - ```bash - sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd - ``` - - - Note that if you're self-hosting Infisical, you can use the `--domain` flag on the `infisical login` command to specify the domain of your Infisical instance. - - For more information on the `infisical ssh add-host` command, please refer to the Infisical CLI [documentation](/cli/overview). - - - If successful, you should see output similar to the following: - - ```bash - โœ… Successfully registered host: - ๐Ÿ“ Wrote User CA public key to: /etc/ssh/infisical_user_ca.pub - ๐Ÿ“ Wrote host certificate to: /etc/ssh/ssh_host_ed25519_key-cert.pub - ๐Ÿ“„ Updated sshd_config entries - ``` - - Finally, use the following command to reload the SSH daemon on the remote host to apply the changes: - - ```bash - sudo systemctl reload sshd - ``` - - - The command may differ depending on the host. For older versions of Ubuntu/Debian/CentOS, you may need to use `sudo service ssh reload` instead; - for Alpine or minimal systems, `/etc/init.d/sshd reload`. - - - Back in Infisical, you should now see the remote host you just registered in the Infisical SSH project you created in step 1 under the **Hosts** tab. - - ![ssh hosts](/images/platform/ssh/v2/ssh-added-hosts.png) - - - - 4.1. Add the user(s) you wish to grant access to the remote host to the Infisical SSH project under Access Control > Users. - - ![ssh hosts](/images/platform/ssh/v2/ssh-add-user.png) - - 4.2. On the registered host in the **Hosts** tab, click **Edit SSH Host** and add a login mapping for the user(s) you added in step 4.1. - - The login mapping dictates what user(s) will be allowed access to the remote host and under a specific login user; in the allowed principals, - you should select user(s) part of the Infisical SSH project that will be allowed to login to the remote host as the login user. - - For instance, if you add a mapping with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to the remote host as `ec2-user` which is a system user that - exists on the remote host. - - ![ssh host mappings](/images/platform/ssh/v2/ssh-host-login-mappings.png) - - - Note that you should configure authorized principals files for each login user you add to the remote host. - - - - - -## User Guide for SSHing to a Host - -Once Infisical SSH is configured by an administrator, users can SSH to the remote host using the Infisical CLI. - - - - Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. - - - The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. - - - - In interactive mode, you'll first need to authenticate with Infisical by running: - - ```bash - infisical login - ``` - - Then simply run: - - ```bash - infisical ssh connect - ``` - - You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by - the administrator. - - ```bash - Use the arrow keys to navigate: โ†“ โ†‘ โ†’ โ† - ? Select an SSH Host: - โ–ธ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com - ``` - - After selecting a host, you'll be prompted to select a login user from a list of allowed login users: - - ```bash - ? Select Login User: - โ–ธ ec2-user - ``` - - If successful, you should be able to SSH to the remote host. - - ```bash - โœ” ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com - โœ” ec2-user - โœ” SSH credentials successfully added to agent - Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... - ``` - - - For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: - - ```bash - infisical ssh connect \ - --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ - --login-user ec2-user \ - --out-file-path ~/.ssh/id_rsa-cert.pub \ - --token - ``` - - This will: - - Connect to the specified hostname - - Use the specified login user - - Write the SSH credentials to the specified path instead of adding them to the SSH agent - - Authenticate using the provided Infisical token - - - - - +- Certificate Authority (CA): Managed CA infrastructure for issuing short-lived SSH certificates. +- Centralized Access Management: View all registered hosts, see who has access to each, and control permissions from a single interface. +- Easy Host Registration & Connection: Quickly register hosts and connect using short-lived certificates without manual key distribution. +- Audit Logging: Detailed records of certificate issuance and SSH access activity. diff --git a/docs/documentation/platform/ssh/usage.mdx b/docs/documentation/platform/ssh/usage.mdx new file mode 100644 index 000000000..d9606214d --- /dev/null +++ b/docs/documentation/platform/ssh/usage.mdx @@ -0,0 +1,201 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." +--- + +## Concept + +Infisical SSH can be configured to provide users on your team short-lived, secure SSH access to infrastructure. Under the hood, it uses SSH certificates +and improves upon traditional SSH key-based authentication by mitigating private key compromise, static key management, +unauthorized access, and SSH key sprawl. + +The following entities are important to understand when configuring and using Infisical SSH: + +- Administrator: An individual on your team who is responsible for configuring Infisical SSH. +- Users: Other individuals that gain access to remote hosts through Infisical SSH. +- Host: A remote machine (e.g. EC2 instance, GCP VM, Azure VM, on-prem Linux server, Raspberry Pi, VMware VM, etc.) that users need SSH access to that is registered with Infisical SSH. + +## Workflow + +The typical workflow for using Infisical SSH consists of the following steps: + +1. The administrator registers a remote host with Infisical using the Infisical CLI via the `infisical ssh add-host` command. +2. The administrator configures Infisical SSH to grant users access to the remote host. +3. User(s) access the remote host using the Infisical CLI via the `infisical ssh connect` command. + +## Admin Guide for Configuring Infisical SSH + +In the following steps, we explore how to configure Infisical SSH to control and streamline your team's SSH access to infrastructure. As part of this guide, +we will register a remote host with Infisical through a [machine identity](/documentation/platform/identities/machine-identities) and configure Infisical to grant user(s) access to the remote host. + + + + Start by creating a new Infisical SSH project in Infisical. + + ![ssh project create](/images/platform/ssh/v2/ssh-create-project.png) + + + 2.1. Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. + + By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the identity to authenticate with Infisical + as part of registering a remote host in step 3. + + + You may use other authentication methods as suitable (e.g. [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), [GCP Auth](/documentation/platform/identities/gcp-auth), etc.) as part of the machine identity configuration but, to keep this example simple, we will be using Universal Auth. + + + 2.2. Add the machine identity to the Infisical SSH project you created in the previous step and assign it the **SSH Host Bootstrapper** role. + + This role grants the ability to **Create** and **Issue Host Certificates** on the **SSH Host** resource; this will enable the linked machine identity to bootstrap a remote host with Infisical + and establish the necessary configuration on it. + + + If you plan to use a custom role to bootstrap SSH hosts, ensure the role has the **Create** and **Issue Host Certificates** on the **SSH Host** resource. + + + ![ssh add identity to project](/images/platform/ssh/v2/ssh-add-identity-to-project.png) + + + + 3.1. Follow the instructions [here](/cli/overview) to install the Infisical CLI onto the remote host. + + 3.2. Run the commands below to register the remote host with Infisical. + + Use the **Client ID** and **Client Secret** from the machine identity you created in step 2.1 as part of the `infisical login` command + to obtain an access token and save it as an environment variable. + + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) + ``` + + Next, use the `infisical ssh add-host` command to register the remote host with Infisical. As part of this command, input the ID of the Infisical SSH project you created in step 1 for the `--projectId` flag and the hostname of the remote host for the `--hostname` flag. + + ```bash + sudo infisical ssh add-host --projectId= --hostname= --token="$INFISICAL_TOKEN" --write-user-ca-to-file --write-host-cert-to-file --configure-sshd + ``` + + + Note that if you're self-hosting Infisical, you can use the `--domain` flag on the `infisical login` command to specify the domain of your Infisical instance. + + For more information on the `infisical ssh add-host` command, please refer to the Infisical CLI [documentation](/cli/overview). + + + If successful, you should see output similar to the following: + + ```bash + โœ… Successfully registered host: + ๐Ÿ“ Wrote User CA public key to: /etc/ssh/infisical_user_ca.pub + ๐Ÿ“ Wrote host certificate to: /etc/ssh/ssh_host_ed25519_key-cert.pub + ๐Ÿ“„ Updated sshd_config entries + ``` + + Finally, use the following command to reload the SSH daemon on the remote host to apply the changes: + + ```bash + sudo systemctl reload sshd + ``` + + + The command may differ depending on the host. For older versions of Ubuntu/Debian/CentOS, you may need to use `sudo service ssh reload` instead; + for Alpine or minimal systems, `/etc/init.d/sshd reload`. + + + Back in Infisical, you should now see the remote host you just registered in the Infisical SSH project you created in step 1 under the **Hosts** tab. + + ![ssh hosts](/images/platform/ssh/v2/ssh-added-hosts.png) + + + + 4.1. Add the user(s) you wish to grant access to the remote host to the Infisical SSH project under Access Control > Users. + + ![ssh hosts](/images/platform/ssh/v2/ssh-add-user.png) + + 4.2. On the registered host in the **Hosts** tab, click **Edit SSH Host** and add a login mapping for the user(s) you added in step 4.1. + + The login mapping dictates what user(s) will be allowed access to the remote host and under a specific login user; in the allowed principals, + you should select user(s) part of the Infisical SSH project that will be allowed to login to the remote host as the login user. + + For instance, if you add a mapping with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to the remote host as `ec2-user` which is a system user that + exists on the remote host. + + ![ssh host mappings](/images/platform/ssh/v2/ssh-host-login-mappings.png) + + + Note that you should configure authorized principals files for each login user you add to the remote host. + + + + + +## User Guide for SSHing to a Host + +Once Infisical SSH is configured by an administrator, users can SSH to the remote host using the Infisical CLI. + + + + Follow the instructions [here](/cli/overview) to install the Infisical CLI onto your local machine. + + + The `infisical ssh connect` command can be used in either interactive or non-interactive mode to connect to a remote host. + + + + In interactive mode, you'll first need to authenticate with Infisical by running: + + ```bash + infisical login + ``` + + Then simply run: + + ```bash + infisical ssh connect + ``` + + You'll be prompted to select an SSH Host from a list of accessible hosts; this is based on project membership and login mappings configured on hosts by + the administrator. + + ```bash + Use the arrow keys to navigate: โ†“ โ†‘ โ†’ โ† + ? Select an SSH Host: + โ–ธ ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com + ``` + + After selecting a host, you'll be prompted to select a login user from a list of allowed login users: + + ```bash + ? Select Login User: + โ–ธ ec2-user + ``` + + If successful, you should be able to SSH to the remote host. + + ```bash + โœ” ec2-54-199-104-116.ap-northeast-1.compute.amazonaws.com + โœ” ec2-user + โœ” SSH credentials successfully added to agent + Connecting to ec2-user@ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com... + ``` + + + For CI/CD pipelines or automation scenarios, you can use the non-interactive mode with an Infisical token: + + ```bash + infisical ssh connect \ + --hostname ec2-12-345-678-910.ap-northeast-1.compute.amazonaws.com \ + --login-user ec2-user \ + --out-file-path ~/.ssh/id_rsa-cert.pub \ + --token + ``` + + This will: + - Connect to the specified hostname + - Use the specified login user + - Write the SSH credentials to the specified path instead of adding them to the SSH agent + - Authenticate using the provided Infisical token + + + + + diff --git a/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png new file mode 100644 index 000000000..9167f1a48 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png new file mode 100644 index 000000000..b2ad14bd7 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png new file mode 100644 index 000000000..6a1b5ce91 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png new file mode 100644 index 000000000..c1fad1a22 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png differ diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 321dbec6e..5c4f34784 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -27,7 +27,7 @@ $ ansible-galaxy collection install infisical.vault The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: ```bash -$ pip install infisical-python +$ pip install infisicalsdk ``` ## Using this collection @@ -41,8 +41,13 @@ vars: read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1 + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" - # [{ "key": "HOST", "value": "google.com" }] + # { "key": "HOST", "value": "google.com" } ``` diff --git a/docs/integrations/platforms/kubernetes/overview.mdx b/docs/integrations/platforms/kubernetes/overview.mdx index ca700d777..ce9afcced 100644 --- a/docs/integrations/platforms/kubernetes/overview.mdx +++ b/docs/integrations/platforms/kubernetes/overview.mdx @@ -22,7 +22,7 @@ It can also automatically reload dependent Deployments resources whenever releva ## Install -The operator can be install via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. +The operator can be installed via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. **Install the latest Helm repository** ```bash @@ -229,9 +229,9 @@ The managed secret created by the operator will not be deleted when the operator - Install Infisical Helm repository + Uninstall Infisical Helm repository ```bash helm uninstall ``` - \ No newline at end of file + diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 0a2d52ce1..9a4e5f342 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions - Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply. + + For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting. + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. diff --git a/docs/integrations/secret-syncs/render.mdx b/docs/integrations/secret-syncs/render.mdx index 341d84ad5..c81048668 100644 --- a/docs/integrations/secret-syncs/render.mdx +++ b/docs/integrations/secret-syncs/render.mdx @@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical." ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) - **Render Connection**: The Render Connection to authenticate with. - - **Scope**: Select **Service**. - - **Service**: Choose the Render service you want to sync secrets to. + - **Scope**: Select **Service** or **Environment Group**. + - **Service**: Choose the Render service you want to sync secrets to. + - **Environment Group**: Choose the Render environment group you want to sync secrets to. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/render/render-sync-options.png) diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index c60db5afc..c823cf4a7 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -142,12 +142,12 @@ Below is a comprehensive list of all available project-level subjects and their Supports conditions and permission inversion | Action | Description | Notes | | -------- | ------------------------------- | ----- | -| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | +| `read` | View secrets and their values | This action is the equivalent of granting both `describeSecret` and `readValue`. The `read` action is considered **legacy**. You should use the `describeSecret` and/or `readValue` actions instead. | | `describeSecret` | View secret details such as key, path, metadata, tags, and more | If you are using the API, you can pass `viewSecretValue: false` to the API call to retrieve secrets without their values. | | `readValue` | View the value of a secret.| In order to read secret values, the `describeSecret` action must also be granted. | -| `create` | Add new secrets to the project | | -| `edit` | Modify existing secret values | | -| `delete` | Remove secrets from the project | | +| `create` | Add new secrets to the project | | +| `edit` | Modify existing secret values | | +| `delete` | Remove secrets from the project | | #### Subject: `secret-folders` @@ -169,6 +169,15 @@ Supports conditions and permission inversion | `edit` | Modify secret imports | | `delete` | Remove secret imports | +#### Subject: `secret-events` + +| Action | Description | +| ------------------------------- | ------------------------------------------------------------- | +| `subscribe-on-created` | Subscribe to events when secrets are created | +| `subscribe-on-updated` | Subscribe to events when secrets are updated | +| `subscribe-on-deleted` | Subscribe to events when secrets are deleted | +| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports | + #### Subject: `secret-rollback` | Action | Description | @@ -178,10 +187,10 @@ Supports conditions and permission inversion #### Subject: `commits` -| Action | Description | -| -------- | ---------------------------------- | -| `read` | View commits and changes across folders | -| `perform-rollback` | Roll back commits changes and restore folders to previous state| +| Action | Description | +| ------------------ | --------------------------------------------------------------- | +| `read` | View commits and changes across folders | +| `perform-rollback` | Roll back commits changes and restore folders to previous state | #### Subject: `secret-approval` @@ -197,14 +206,14 @@ Supports conditions and permission inversion #### Subject: `secret-rotation` Supports conditions and permission inversion -| Action | Description | +| Action | Description | | ------------------------------ | ---------------------------------------------- | -| `read` | View secret rotation configurations | -| `read-generated-credentials` | View the generated credentials of a rotation | -| `create` | Set up secret rotation configurations | -| `edit` | Modify secret rotation configurations | -| `rotate-secrets` | Rotate the generated credentials of a rotation | -| `delete` | Remove secret rotation configurations | +| `read` | View secret rotation configurations | +| `read-generated-credentials` | View the generated credentials of a rotation | +| `create` | Set up secret rotation configurations | +| `edit` | Modify secret rotation configurations | +| `rotate-secrets` | Rotate the generated credentials of a rotation | +| `delete` | Remove secret rotation configurations | #### Subject: `secret-syncs` @@ -263,12 +272,12 @@ Supports conditions and permission inversion #### Subject: `certificates` -| Action | Description | -| -------------------- | ----------------------------- | -| `read` | View certificates | -| `read-private-key` | Read certificate private key | -| `create` | Issue new certificates | -| `delete` | Revoke or remove certificates | +| Action | Description | +| ------------------ | ----------------------------- | +| `read` | View certificates | +| `read-private-key` | Read certificate private key | +| `create` | Issue new certificates | +| `delete` | Revoke or remove certificates | #### Subject: `certificate-templates` @@ -330,8 +339,8 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-data-sources` -| Action | Description | -| -------- | ---------------------------------------------------- | +| Action | Description | +| ---------------------------- | -------------------------------- | | `read-data-sources` | View Data Sources | | `create-data-sources` | Create new Data Sources | | `edit-data-sources` | Modify Data Sources | @@ -342,15 +351,14 @@ Supports conditions and permission inversion #### Subject: `secret-scanning-findings` -| Action | Description | -| -------- | --------------------------------- | -| `read-findings` | View Secret Scanning Findings | -| `update-findings` | Update Secret Scanning Findings | - +| Action | Description | +| ----------------- | ------------------------------- | +| `read-findings` | View Secret Scanning Findings | +| `update-findings` | Update Secret Scanning Findings | #### Subject: `secret-scanning-configs` -| Action | Description | -| ---------------- | ------------------------------------------------ | -| `read-configs` | View Secret Scanning Project Configuration | -| `update-configs` | Update Secret Scanning Project Configuration | +| Action | Description | +| ---------------- | -------------------------------------------- | +| `read-configs` | View Secret Scanning Project Configuration | +| `update-configs` | Update Secret Scanning Project Configuration | diff --git a/docs/internals/security.mdx b/docs/internals/security.mdx index 85138be9c..c2ca3e141 100644 --- a/docs/internals/security.mdx +++ b/docs/internals/security.mdx @@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag ## Cross-Region Replication for Disaster Recovery (Infisical Cloud) -To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed. +To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed. - ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down. -- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage. +- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues. -With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages. ## Penetration testing diff --git a/docs/self-hosting/guides/mongo-to-postgres.mdx b/docs/self-hosting/guides/mongo-to-postgres.mdx index b8781a19d..918a9e411 100644 --- a/docs/self-hosting/guides/mongo-to-postgres.mdx +++ b/docs/self-hosting/guides/mongo-to-postgres.mdx @@ -196,6 +196,17 @@ The first deployment of Postgres based Infisical must be deployed with Docker im After deploying this version, you can proceed to update to any subsequent versions. -## Additional discussion -- When you visit Infisical's [docker hub](https://hub.docker.com/r/infisical/infisical) page, you will notice that image tags end with `-postgres`. -This is to indicate that this version of Infisical runs on the new Postgres backend. Any image tag that does not end in `postgres` runs on MongoDB. \ No newline at end of file +## Important Notes + +Infisical's [Docker Hub repository](https://hub.docker.com/r/infisical/infisical) uses different tagging conventions to indicate which database backend is used: + +- **Before v0.46.11** + - Infisical ran on MongoDB backend + +- **After v0.46.11** + - Version tags started to be suffixed with `-postgres` + - Infisical transitioned to PostgreSQL backend + +- **After v0.147.0** + - Infisical remains on PostgreSQL backend + - The `-postgres` suffix was removed from tags for brevity \ No newline at end of file diff --git a/docs/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx new file mode 100644 index 000000000..1c2d05702 --- /dev/null +++ b/docs/self-hosting/guides/monitoring-telemetry.mdx @@ -0,0 +1,440 @@ +--- +title: "Monitoring and Telemetry Setup" +description: "Learn how to set up monitoring and telemetry for your self-hosted Infisical instance using Grafana, Prometheus, and OpenTelemetry." +--- + +Infisical provides comprehensive monitoring and telemetry capabilities to help you monitor the health, performance, and usage of your self-hosted instance. This guide covers setting up monitoring using Grafana with two different telemetry collection approaches. + +## Overview + +Infisical exports metrics in **OpenTelemetry (OTEL) format**, which provides maximum flexibility for your monitoring infrastructure. While this guide focuses on Grafana, the OTEL format means you can easily integrate with: + +- **Cloud-native monitoring**: AWS CloudWatch, Google Cloud Monitoring, Azure Monitor +- **Observability platforms**: Datadog, New Relic, Splunk, Dynatrace +- **Custom backends**: Any system that supports OTEL ingestion +- **Traditional monitoring**: Prometheus, Grafana (as covered in this guide) + +Infisical supports two telemetry collection methods: + +1. **Pull-based (Prometheus)**: Exposes metrics on a dedicated endpoint for Prometheus to scrape +2. **Push-based (OTLP)**: Sends metrics to an OpenTelemetry Collector via OTLP protocol + +Both approaches provide the same metrics data in OTEL format, so you can choose the one that best fits your infrastructure and monitoring strategy. + +## Prerequisites + +- Self-hosted Infisical instance running +- Access to deploy monitoring services (Prometheus, Grafana, etc.) +- Basic understanding of Prometheus and Grafana + +## Environment Variables + +Configure the following environment variables in your Infisical backend: + +```bash +# Enable telemetry collection +OTEL_TELEMETRY_COLLECTION_ENABLED=true + +# Choose export type: "prometheus" or "otlp" +OTEL_EXPORT_TYPE=prometheus + +# For OTLP push mode, also configure: +# OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics +# OTEL_COLLECTOR_BASIC_AUTH_USERNAME=your_collector_username +# OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=your_collector_password +# OTEL_OTLP_PUSH_INTERVAL=30000 +``` + +**Note**: The `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` values must match the credentials configured in your OpenTelemetry Collector's `basicauth/server` extension. These are not hardcoded values - you configure them in your collector configuration file. + +## Option 1: Pull-based Monitoring (Prometheus) + +This approach exposes metrics on port 9464 at the `/metrics` endpoint, allowing Prometheus to scrape the data. The metrics are exposed in Prometheus format but originate from OpenTelemetry instrumentation. + +### Configuration + +1. **Enable Prometheus export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=prometheus + ``` + +2. **Expose the metrics port** in your Infisical backend: + + - **Docker**: Expose port 9464 + - **Kubernetes**: Create a service exposing port 9464 + - **Other**: Ensure port 9464 is accessible to your monitoring stack + +3. **Create Prometheus configuration** (`prometheus.yml`): + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "infisical" + scrape_interval: 30s + static_configs: + - targets: ["infisical-backend:9464"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `infisical-backend:9464` with the actual hostname and port where your Infisical backend is running. This could be: + + - **Docker Compose**: `infisical-backend:9464` (service name) + - **Kubernetes**: `infisical-backend.default.svc.cluster.local:9464` (service name) + - **Bare Metal**: `192.168.1.100:9464` (actual IP address) + - **Cloud**: `your-infisical.example.com:9464` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin +``` + +#### Kubernetes + +```yaml +# prometheus-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:latest + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + volumes: + - name: config + configMap: + name: prometheus-config + +--- +# prometheus-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: prometheus +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 + type: ClusterIP +``` + +#### Helm + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm install prometheus prometheus-community/prometheus \ + --set server.config.global.scrape_interval=30s \ + --set server.config.scrape_configs[0].job_name=infisical \ + --set server.config.scrape_configs[0].static_configs[0].targets[0]=infisical-backend:9464 +``` + +## Option 2: Push-based Monitoring (OTLP) + +This approach sends metrics directly to an OpenTelemetry Collector via the OTLP protocol. This gives you the most flexibility as you can configure the collector to export to multiple backends simultaneously. + +### Configuration + +1. **Enable OTLP export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=otlp + OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics + OTEL_COLLECTOR_BASIC_AUTH_USERNAME=infisical + OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=infisical + OTEL_OTLP_PUSH_INTERVAL=30000 + ``` + +2. **Create OpenTelemetry Collector configuration** (`otel-collector-config.yaml`): + + ```yaml + extensions: + health_check: + pprof: + zpages: + basicauth/server: + htpasswd: + inline: | + your_username:your_password + + receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + auth: + authenticator: basicauth/server + + prometheus: + config: + scrape_configs: + - job_name: otel-collector + scrape_interval: 30s + static_configs: + - targets: [infisical-backend:9464] + metric_relabel_configs: + - action: labeldrop + regex: "service_instance_id|service_name" + + processors: + batch: + + exporters: + prometheus: + endpoint: "0.0.0.0:8889" + auth: + authenticator: basicauth/server + resource_to_telemetry_conversion: + enabled: true + + service: + extensions: [basicauth/server, health_check, pprof, zpages] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] + ``` + + **Important**: Replace `your_username:your_password` with your chosen credentials. These must match the values you set in Infisical's `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` environment variables. + +3. **Create Prometheus configuration** for the collector: + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "otel-collector" + scrape_interval: 30s + static_configs: + - targets: ["otel-collector:8889"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `otel-collector:8889` with the actual hostname and port where your OpenTelemetry Collector is running. This could be: + + - **Docker Compose**: `otel-collector:8889` (service name) + - **Kubernetes**: `otel-collector.default.svc.cluster.local:8889` (service name) + - **Bare Metal**: `192.168.1.100:8889` (actual IP address) + - **Cloud**: `your-collector.example.com:8889` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + ports: + - 4318:4318 # OTLP http receiver + - 8889:8889 # Prometheus exporter metrics + volumes: + - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + command: + - "--config=/etc/otelcol-contrib/config.yaml" +``` + +#### Kubernetes + +```yaml +# otel-collector-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:latest + ports: + - containerPort: 4318 + - containerPort: 8889 + volumeMounts: + - name: config + mountPath: /etc/otelcol-contrib + volumes: + - name: config + configMap: + name: otel-collector-config +``` + +#### Helm + +```bash +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm install otel-collector open-telemetry/opentelemetry-collector \ + --set config.receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \ + --set config.exporters.prometheus.endpoint=0.0.0.0:8889 +``` + +## Alternative Backends + +Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus: + +### Cloud-Native Examples + +```yaml +# Add to your otel-collector-config.yaml exporters section +exporters: + # AWS CloudWatch + awsemf: + region: us-west-2 + log_group_name: /aws/emf/infisical + log_stream_name: metrics + + # Google Cloud Monitoring + googlecloud: + project_id: your-project-id + + # Azure Monitor + azuremonitor: + connection_string: "your-connection-string" + + # Datadog + datadog: + api: + key: "your-api-key" + site: "datadoghq.com" + + # New Relic + newrelic: + apikey: "your-api-key" + host_override: "otlp.nr-data.net" +``` + +### Multi-Backend Configuration + +```yaml +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus, awsemf, datadog] # Send to multiple backends +``` + +## Setting Up Grafana + +1. **Access Grafana**: Navigate to your Grafana instance +2. **Login**: Use your configured credentials +3. **Add Prometheus Data Source**: + - Go to Configuration โ†’ Data Sources + - Click "Add data source" + - Select "Prometheus" + - Set URL to your Prometheus endpoint + - Click "Save & Test" + +## Available Metrics + +Infisical exposes the following key metrics in OpenTelemetry format: + +### API Performance Metrics + +- `API_latency` - API request latency histogram in milliseconds + + - **Labels**: `route`, `method`, `statusCode` + - **Example**: Monitor response times for specific endpoints + +- `API_errors` - API error count histogram + - **Labels**: `route`, `method`, `type`, `name` + - **Example**: Track error rates by endpoint and error type + +### Integration & Secret Sync Metrics + +- `integration_secret_sync_errors` - Integration secret sync error count + + - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` + - **Example**: Monitor integration sync failures across different services + +- `secret_sync_sync_secrets_errors` - Secret sync operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret sync failures to external systems + +- `secret_sync_import_secrets_errors` - Secret import operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Monitor secret import failures + +- `secret_sync_remove_secrets_errors` - Secret removal operation error count + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret removal operation failures + +### System Metrics + +These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: + +- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) +- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) + +### Custom Business Metrics + +- `infisical_secret_operations_total` - Total secret operations +- `infisical_secrets_processed_total` - Total secrets processed + +## Troubleshooting + +### Common Issues + +1. **Metrics not appearing**: + + - Check if `OTEL_TELEMETRY_COLLECTION_ENABLED=true` + - Verify the correct `OTEL_EXPORT_TYPE` is set + - Check network connectivity between services + +2. **Authentication errors**: + + - Verify basic auth credentials in OTLP configuration + - Check if credentials match between Infisical and collector diff --git a/docs/self-hosting/guides/releases.mdx b/docs/self-hosting/guides/releases.mdx new file mode 100644 index 000000000..d85da1a78 --- /dev/null +++ b/docs/self-hosting/guides/releases.mdx @@ -0,0 +1,66 @@ +--- +title: "Release Channels" +description: "Learn how to configure your deployment for different release schedules." +--- + +Infisical uses rolling release channels to deliver new features, security fixes, and improvements with different update frequencies. +This system allows you to balance getting the latest features with maintaining stability in your deployment environment. + +## What are release channels? + +Release channels define different schedules under which Infisical makes new releases available for you to deploy. +Each channel operates on its own cadence, allowing you to choose how frequently you want to update your self-hosted deployment while balancing access to the latest features with your organization's stability requirements. + + +## Available Channels + +Infisical provides two distinct release channels with different update frequencies and stability profiles. + + + + - **Update Frequency**: Daily builds during weekdays (Monday-Friday) + - **Version Tags**: `vX.Y.Z-nightly-YYYYMMDD` (e.g., `v0.146.0-nightly-20250423`) + - **Multiple Daily Builds**: If multiple nightly builds are created on the same day, they are numbered incrementally: `vX.Y.Z-nightly-YYYYMMDD.1`, `vX.Y.Z-nightly-YYYYMMDD.2`, etc. + - **Stability**: Latest features with standard CI/CD testing + - **Release Process**: Built from main branch after all automated tests pass + - **Intended Audience**: Development environments & early adopters + + **Best for:** + - Organizations with flexible change management + - Teams that want to test new features before they are made available via stable release channel + + **Characteristics:** + - Access to latest features immediately + - Faster security patch delivery + - Higher update frequency (daily) + + + - **Update Frequency**: Monthly releases (typically 1st Tuesday of each month) + - **Version Tags**: `vX.Y.Z` (e.g., `v0.145.0`, `v0.146.0`) + - **Stability**: Both code tested and production-proven releases + - **Release Process**: Features validated through nightly channel for 30+ days + - **Intended Audience**: Production environments, enterprise teams prioritizing stability + + **Best for:** + - Enterprise environments with limited change windows + - Organizations requiring predictable release cycles + - Teams prioritizing stability and long-term support + + **Characteristics:** + - Predictable monthly schedule + - Extensive testing and validation + - Production-proven features + + + + + Schedule Note: Target dates are approximate and subject to change based on critical issues, security updates, or maintenance requirements. + + +## Staying up to date + +Track what features are available in each version across different sources: + +- **Released versions**: View detailed changelogs, new features, and breaking changes in our [GitHub Releases](https://github.com/Infisical/infisical/releases) +- **Docker Image Versions**: Browse available container images and tags on [Docker Hub](https://hub.docker.com/r/infisical/infisical) +- **Linux Package Releases**: Access downloadable packages and version history on our [Linux releases page](https://cloudsmith.io/~infisical/repos/infisical-core/packages/) diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index 167e1c7b5..fdfefcf93 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -53,8 +53,8 @@ "project-id": "Project ID", "save-changes": "Save Changes", "saved": "Saved", - "drop-zone": "Drag and drop a .env, .json, or .yml file here.", - "drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.", + "drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.", + "drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.", "role": "Role", "role_admin": "admin", "display-name": "Display Name", diff --git a/frontend/src/components/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index fdb9aea5e..83f058c7a 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -1,4 +1,4 @@ -import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormLabel, Tooltip } from "../v2"; @@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( label={label} icon={ + Examples: 30m, 1h, 3d, etc.{" "} - More + See More Examples{" "} + } @@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( } diff --git a/frontend/src/components/roles/RoleOption.tsx b/frontend/src/components/roles/RoleOption.tsx new file mode 100644 index 000000000..857a13c4c --- /dev/null +++ b/frontend/src/components/roles/RoleOption.tsx @@ -0,0 +1,29 @@ +import { components, OptionProps } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export const RoleOption = ({ + isSelected, + children, + ...props +}: OptionProps<{ name: string; slug: string; description?: string | undefined }>) => { + return ( + +
+
+

{children}

+ {props.data.description ? ( +

+ {props.data.description} +

+ ) : ( +

No Description

+ )} +
+ {isSelected && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/roles/index.tsx b/frontend/src/components/roles/index.tsx new file mode 100644 index 000000000..61f1aaa67 --- /dev/null +++ b/frontend/src/components/roles/index.tsx @@ -0,0 +1 @@ +export * from "./RoleOption"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx index 3d3f8df93..b88e0d6e6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { + TRenderEnvironmentGroup, TRenderService, + useRenderConnectionListEnvironmentGroups, useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; import { SecretSync } from "@app/hooks/api/secretSyncs"; @@ -19,6 +21,7 @@ export const RenderSyncFields = () => { >(); const connectionId = useWatch({ name: "connection.id", control }); + const selectedScope = useWatch({ name: "destinationConfig.scope", control }); const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( connectionId, @@ -27,11 +30,17 @@ export const RenderSyncFields = () => { } ); + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(connectionId, { + enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup + }); + return ( <> { setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.environmentGroupId", ""); setValue("destinationConfig.type", RenderSyncType.Env); setValue("destinationConfig.scope", RenderSyncScope.Service); }} @@ -83,30 +92,67 @@ export const RenderSyncFields = () => { )} /> - ( - - service.id === value) ?? []) : []} - onChange={(option) => { - onChange((option as SingleValue)?.id ?? null); - setValue( - "destinationConfig.serviceName", - (option as SingleValue)?.name ?? "" - ); - }} - options={services} - placeholder="Select a service..." - getOptionLabel={(option) => option.name} - getOptionValue={(option) => option.id.toString()} - /> - - )} - /> + {selectedScope === RenderSyncScope.Service && ( + ( + + service.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.serviceName", + (option as SingleValue)?.name ?? "" + ); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + {selectedScope === RenderSyncScope.EnvironmentGroup && ( + ( + + g.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.environmentGroupName", + (option as SingleValue)?.name ?? "" + ); + }} + options={groups} + placeholder="Select an environment group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index 15f5b6625..f195f0e72 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; export const RenderSyncOptionsReviewFields = () => { const { watch } = useFormContext(); @@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => { export const RenderSyncReviewFields = () => { const { watch } = useFormContext(); - const serviceName = watch("destinationConfig.serviceName"); - const scope = watch("destinationConfig.scope"); + const config = watch("destinationConfig"); return ( <> - {scope} - {serviceName} + {config.scope} + {config.scope === RenderSyncScope.Service ? ( + + {config.serviceName ?? config.serviceId} + + ) : ( + + {config.environmentGroupName ?? config.environmentGroupId} + + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts index 83e5347eb..b65705c14 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts @@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema( serviceId: z.string().trim().min(1, "Service is required"), serviceName: z.string().trim().optional(), type: z.nativeEnum(RenderSyncType) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup), + environmentGroupId: z.string().trim().min(1, "Environment Group ID is required"), + environmentGroupName: z.string().trim().optional(), + type: z.nativeEnum(RenderSyncType) }) ]) }) diff --git a/frontend/src/components/utilities/parseSecrets.ts b/frontend/src/components/utilities/parseSecrets.ts index 3e5a57edf..e33f25ace 100644 --- a/frontend/src/components/utilities/parseSecrets.ts +++ b/frontend/src/components/utilities/parseSecrets.ts @@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) { return result; } + +function detectSeparator(csvContent: string): string { + const firstLine = csvContent.split("\n")[0]; + const separators = [",", ";", "\t", "|"]; + + const counts = separators.map((sep) => ({ + separator: sep, + count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length + })); + + const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max)); + + return detected.count > 0 ? detected.separator : ","; +} + +export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] { + let csvContent: string; + if (typeof src === "string") { + csvContent = src; + } else { + csvContent = new TextDecoder("utf-8").decode(src); + } + + const separator = detectSeparator(csvContent); + const lines = csvContent.replace(/\r\n?/g, "\n").split("\n"); + const matrix: string[][] = []; + + lines.forEach((line) => { + if (line.trim() !== "") { + const cells: string[] = []; + let currentCell = ""; + let inQuote = false; + + for (let i = 0; i < line.length; i += 1) { + const char = line[i]; + const nextChar = line[i + 1]; + + if (char === '"') { + if (inQuote && nextChar === '"') { + currentCell += '"'; + i += 1; + } else { + inQuote = !inQuote; + } + } else if (char === separator && !inQuote) { + cells.push(currentCell.trim()); + currentCell = ""; + } else { + currentCell += char; + } + } + cells.push(currentCell.trim()); + matrix.push(cells); + } + }); + + return matrix; +} diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 3c0f9df32..dad72cada 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions { Update = "update-configs" } +export enum ProjectPermissionSecretEventActions { + SubscribeCreated = "subscribe-on-created", + SubscribeUpdated = "subscribe-on-updated", + SubscribeDeleted = "subscribe-on-deleted", + SubscribeImportMutations = "subscribe-on-import-mutations" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.CertificateTemplates | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports - | ProjectPermissionSub.SecretRotation; + | ProjectPermissionSub.SecretRotation + | ProjectPermissionSub.SecretEvents; export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = { [PermissionConditionOperators.$EQ]: "equal to", @@ -250,7 +258,8 @@ export enum ProjectPermissionSub { Commits = "commits", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", - SecretScanningConfigs = "secret-scanning-configs" + SecretScanningConfigs = "secret-scanning-configs", + SecretEvents = "secret-events" } export type SecretSubjectFields = { @@ -260,6 +269,14 @@ export type SecretSubjectFields = { secretTags: string[]; }; +export type SecretEventSubjectFields = { + environment: string; + secretPath: string; + secretName: string; + secretTags: string[]; + action: string; +}; + export type SecretFolderSubjectFields = { environment: string; secretPath: string; @@ -403,6 +420,13 @@ export type ProjectPermissionSet = ProjectPermissionSub.SecretScanningDataSources ] | [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings] - | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]; + | [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs] + | [ + ProjectPermissionSecretEventActions, + ( + | ProjectPermissionSub.SecretEvents + | (ForcedSubject & SecretEventSubjectFields) + ) + ]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/helpers/key.ts b/frontend/src/helpers/key.ts deleted file mode 100644 index c516826f1..000000000 --- a/frontend/src/helpers/key.ts +++ /dev/null @@ -1,84 +0,0 @@ -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; - -/** - * @param {Object} obj - * @param {Number} obj.encryptionVersion - * @param {String} obj.encryptedPrivateKey - * @param {String} obj.iv - * @param {String} obj.tag - * @param {String} obj.password - * @param {String} obj.salt - * @param {String} obj.protectedKey - * @param {String} obj.protectedKeyIV - * @param {String} obj.protectedKeyTag - */ -const decryptPrivateKeyHelper = async ({ - encryptionVersion, - encryptedPrivateKey, - iv, - tag, - password, - salt, - protectedKey, - protectedKeyIV, - protectedKeyTag -}: { - encryptionVersion: number; - encryptedPrivateKey: string; - iv: string; - tag: string; - password: string; - salt: string; - protectedKey?: string; - protectedKeyIV?: string; - protectedKeyTag?: string; -}) => { - let privateKey; - try { - if (encryptionVersion === 1) { - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: password - .slice(0, 32) - .padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0") - }); - } else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) { - const derivedKey = await deriveArgonKey({ - password, - salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); - - if (!derivedKey) throw new Error("Failed to generate derived key"); - - const key = Aes256Gcm.decrypt({ - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag, - secret: Buffer.from(derivedKey.hash) - }); - - // decrypt back the private key - privateKey = Aes256Gcm.decrypt({ - ciphertext: encryptedPrivateKey, - iv, - tag, - secret: Buffer.from(key, "hex") - }); - } else { - throw new Error("Insufficient details to decrypt private key"); - } - } catch { - throw new Error("Failed to decrypt private key"); - } - - return privateKey; -}; - -export { decryptPrivateKeyHelper }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 8da61583e..6ce82cfb2 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record { @@ -26,7 +28,8 @@ export const useCreateAccessApprovalPolicy = () => { secretPath, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { environments, @@ -38,7 +41,8 @@ export const useCreateAccessApprovalPolicy = () => { name, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + maxTimePeriod }); return data; }, @@ -64,7 +68,8 @@ export const useUpdateAccessApprovalPolicy = () => { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, @@ -75,7 +80,8 @@ export const useUpdateAccessApprovalPolicy = () => { enforcementLevel, allowedSelfApprovals, approvalsRequired, - environments + environments, + maxTimePeriod }); return data; }, @@ -130,6 +136,25 @@ export const useCreateAccessRequest = () => { }); }; +export const useUpdateAccessRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ requestId, ...payload }) => { + const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>( + `/api/v1/access-approvals/requests/${requestId}`, + payload + ); + + return data.approval; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug) + }); + } + }); +}; + export const useReviewAccessRequest = () => { const queryClient = useQueryClient(); return useMutation< diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index c53f9d013..44f260f66 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -24,7 +24,7 @@ export const accessApprovalKeys = { envSlug?: string, requestedBy?: string, bypassReason?: string - ) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, + ) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const, getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) => [{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const }; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index bc569165b..5063f4fff 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,6 +18,7 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; bypassers?: Bypasser[]; allowedSelfApprovals: boolean; + maxTimePeriod?: string | null; }; export enum ApproverType { @@ -35,6 +36,7 @@ export type Approver = { type: ApproverType; sequence?: number; approvalsRequired?: number; + isOrgMembershipActive: boolean; }; export type Bypasser = { @@ -81,6 +83,7 @@ export type TAccessApprovalRequest = { name: string; approvals: number; approvers: { + isOrgMembershipActive: boolean; userId: string; sequence?: number; approvalsRequired?: number; @@ -93,14 +96,18 @@ export type TAccessApprovalRequest = { enforcementLevel: EnforcementLevel; deletedAt: Date | null; allowedSelfApprovals: boolean; + maxTimePeriod?: string | null; }; reviewers: { + isOrgMembershipActive: boolean; userId: string; status: string; }[]; note?: string; + editNote?: string; + editedByUserId?: string; }; export type TAccessApproval = { @@ -144,6 +151,13 @@ export type TCreateAccessRequestDTO = { note?: string; } & Omit; +export type TUpdateAccessRequestDTO = { + requestId: string; + editNote: string; + temporaryRange: string; + projectSlug: string; +}; + export type TGetAccessApprovalRequestsDTO = { projectSlug: string; policyId?: string; @@ -166,19 +180,20 @@ export type TCreateAccessPolicyDTO = { projectSlug: string; name?: string; environments: string[]; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; secretPath: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string | null; }; export type TUpdateAccessPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; environments?: string[]; @@ -188,6 +203,7 @@ export type TUpdateAccessPolicyDTO = { // for invalidating list projectSlug: string; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + maxTimePeriod?: string | null; }; export type TDeleteSecretPolicyDTO = { diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index fe2cc3eed..aca8b5bb3 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = { password: string; firstName: string; lastName?: string; - protectedKey: string; - protectedKeyTag: string; - protectedKeyIV: string; - encryptedPrivateKey: string; - encryptedPrivateKeyIV: string; - encryptedPrivateKeyTag: string; - publicKey: string; - verifier: string; - salt: string; }; export type AdminGetOrganizationsFilters = { diff --git a/frontend/src/hooks/api/appConnections/render/queries.tsx b/frontend/src/hooks/api/appConnections/render/queries.tsx index 728c46bd9..93ec24738 100644 --- a/frontend/src/hooks/api/appConnections/render/queries.tsx +++ b/frontend/src/hooks/api/appConnections/render/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TRenderService } from "./types"; +import { TRenderEnvironmentGroup, TRenderService } from "./types"; const renderConnectionKeys = { all: [...appConnectionKeys.all, "render"] as const, listServices: (connectionId: string) => - [...renderConnectionKeys.all, "services", connectionId] as const + [...renderConnectionKeys.all, "services", connectionId] as const, + listEnvironmentGroups: (connectionId: string) => + [...renderConnectionKeys.all, "environment-groups", connectionId] as const }; export const useRenderConnectionListServices = ( @@ -35,3 +37,28 @@ export const useRenderConnectionListServices = ( ...options }); }; + +export const useRenderConnectionListEnvironmentGroups = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRenderEnvironmentGroup[], + unknown, + TRenderEnvironmentGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: renderConnectionKeys.listEnvironmentGroups(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/render/${connectionId}/environment-groups` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/types.ts b/frontend/src/hooks/api/appConnections/render/types.ts index ec51adb78..d422a7ae5 100644 --- a/frontend/src/hooks/api/appConnections/render/types.ts +++ b/frontend/src/hooks/api/appConnections/render/types.ts @@ -2,3 +2,8 @@ export type TRenderService = { id: string; name: string; }; + +export type TRenderEnvironmentGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts index e347e4493..2d04334e6 100644 --- a/frontend/src/hooks/api/dynamicSecret/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => { ); return data.dynamicSecret; }, - onSuccess: (_, { path, environmentSlug, projectSlug }) => { + onSuccess: (_, { path, environmentSlug, projectSlug, name }) => { // TODO: optimize but currently don't pass projectId queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); queryClient.invalidateQueries({ queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug }) }); + queryClient.invalidateQueries({ + queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name }) + }); } }); }; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 84b618153..289bc1d04 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -37,7 +37,8 @@ export enum DynamicSecretProviders { Kubernetes = "kubernetes", Vertica = "vertica", GcpIam = "gcp-iam", - Github = "github" + Github = "github", + Couchbase = "couchbase" } export enum KubernetesDynamicSecretCredentialType { @@ -353,6 +354,38 @@ export type TDynamicSecretProvider = installationId: number; privateKey: string; }; + } + | { + type: DynamicSecretProviders.Couchbase; + inputs: { + url: string; + orgId: string; + projectId: string; + clusterId: string; + roles: string[]; + buckets: + | string + | Array<{ + name: string; + scopes?: Array<{ + name: string; + collections?: string[]; + }>; + }>; + passwordRequirements?: { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; + }; + auth: { + apiKey: string; + }; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index cd620bb64..15e1b861c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -104,6 +104,7 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, orgId, @@ -125,6 +126,7 @@ export const useUpdateOrg = () => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, defaultMembershipRoleSlug, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 73267f0f6..71f2a8b90 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; @@ -34,6 +35,7 @@ export type UpdateOrgDTO = { orgId: string; name?: string; authEnforced?: boolean; + googleSsoAuthEnforced?: boolean; scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 8fd86624d..1785ffa8a 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -20,6 +20,7 @@ export enum ApproverType { } export type Approver = { + isOrgMembershipActive: boolean; id: string; type: ApproverType; }; @@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = { name?: string; environments: string[]; secretPath: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; @@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; approvals?: number; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index caf1df320..0eef3fa3d 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -53,6 +53,7 @@ export type TSecretApprovalRequest = { firstName: string; lastName: string; username: string; + isOrgMembershipActive: boolean; }[]; workspace: string; environment: string; @@ -62,6 +63,7 @@ export type TSecretApprovalRequest = { status: "open" | "close"; policy: Omit & { approvers: { + isOrgMembershipActive: boolean; userId: string; email: string; firstName: string; diff --git a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts index 3d66de623..4d217e840 100644 --- a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts @@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ export type TRenderSync = TRootSecretSync & { destination: SecretSync.Render; - destinationConfig: { - scope: RenderSyncScope.Service; - type: RenderSyncType; - serviceId: string; - serviceName?: string; - }; + destinationConfig: + | { + type: RenderSyncType; + scope: RenderSyncScope.Service; + serviceId: string; + serviceName?: string | undefined; + } + | { + type: RenderSyncType; + scope: RenderSyncScope.EnvironmentGroup; + environmentGroupId: string; + environmentGroupName?: string | undefined; + }; connection: { app: AppConnection.Render; @@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & { }; export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 4ded71cfe..338599fe0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -48,6 +48,7 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + enforceGoogleSSO: boolean; projectTemplates: boolean; kmip: boolean; secretScanning: boolean; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 3af283b7e..0a43e850e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -83,6 +83,7 @@ export type TProjectMembership = { export type TWorkspaceUser = { id: string; user: { + isOrgMembershipActive: boolean; email: string; username: string; firstName: string; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..5c37d4404 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -240,6 +240,13 @@ export const Navbar = () => { return; } + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + handleOrgChange(org?.id); }} variant="plain" diff --git a/frontend/src/lib/crypto/index.ts b/frontend/src/lib/crypto/index.ts deleted file mode 100644 index d722b68ea..000000000 --- a/frontend/src/lib/crypto/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import crypto from "crypto"; - -import jsrp from "jsrp"; - -import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; -import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto"; - -export const generateUserPassKey = async ( - email: string, - password: string, - fipsEnabled: boolean -) => { - // eslint-disable-next-line new-cap - const client = new jsrp.client(); - - const { publicKey, privateKey } = await generateKeyPair(fipsEnabled); - - await new Promise((resolve) => { - client.init({ username: email, password }, () => resolve(null)); - }); - const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>( - (resolve, reject) => { - client.createVerifier((err, res) => { - if (err) return reject(err); - return resolve(res); - }); - } - ); - - const derivedKey = await deriveArgonKey({ - password, - salt, - mem: 65536, - time: 3, - parallelism: 1, - hashLen: 32 - }); - - if (!derivedKey) throw new Error("Failed to derive key from password"); - - const key = crypto.randomBytes(32); - - // create encrypted private key by encrypting the private - // key with the symmetric key [key] - const { - ciphertext: encryptedPrivateKey, - iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag - } = Aes256Gcm.encrypt({ - text: privateKey, - secret: key - }); - - // create the protected key by encrypting the symmetric key - // [key] with the derived key - const { - ciphertext: protectedKey, - iv: protectedKeyIV, - tag: protectedKeyTag - } = Aes256Gcm.encrypt({ - text: key.toString("hex"), - secret: Buffer.from(derivedKey.hash) - }); - - return { - protectedKey, - protectedKeyTag, - protectedKeyIV, - encryptedPrivateKey, - encryptedPrivateKeyIV, - encryptedPrivateKeyTag, - publicKey, - verifier, - salt, - privateKey - }; -}; diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index 19c1537a2..f7f3649ca 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -12,7 +12,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api"; -import { generateUserPassKey } from "@app/lib/crypto"; const formSchema = z .object({ @@ -48,17 +47,11 @@ export const SignUpPage = () => { // avoid multi submission if (isSubmitting) return; try { - const { privateKey, ...userPass } = await generateUserPassKey( - email, - password, - config.fipsEnabled - ); const res = await createAdminUser({ email, password, firstName, - lastName, - ...userPass + lastName }); SecurityClient.setToken(res.token); diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index f331e4bda..9e6850f82 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => { } } - if (organization.authEnforced && !canBypassOrgAuth) { + if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) { + const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod }; + // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO - await logout.mutateAsync(); let url = ""; if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; - } else { + } else if (organization.orgAuthMethod === AuthMethod.SAML) { url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; if (callbackPort) { url += `?callback_port=${callbackPort}`; } + } else if ( + organization.googleSsoAuthEnforced && + authToken.authMethod !== AuthMethod.GOOGLE + ) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } } - window.location.href = url; - return; + // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO + // see line 103-106 + if (url) { + await logout.mutateAsync(); + window.location.href = url; + return; + } } const { token, isMfaEnabled, mfaMethod } = await selectOrg diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index faba9ef37..e0bc547e0 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { SingleValue } from "react-select"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); - const availableConnections: TAvailableAppConnection[] = [ - ...(availableRoute53Connections || []), - ...(availableCloudflareConnections || []) - ]; + const availableConnections: TAvailableAppConnection[] = useMemo( + () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])], + [availableRoute53Connections, availableCloudflareConnections] + ); const isPending = isRoute53Pending || isCloudflarePending; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx index ec104d391..7f751fc7d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/AddOrgMemberModal.tsx @@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { RoleOption } from "@app/components/roles"; import { Button, FilterableSelect, @@ -45,7 +46,11 @@ const addMemberFormSchema = z.object({ ) .default([]), projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG), - organizationRole: z.object({ name: z.string(), slug: z.string() }) + organizationRole: z.object({ + name: z.string(), + slug: z.string(), + description: z.string().optional() + }) }); type TAddMemberForm = z.infer; @@ -238,6 +243,7 @@ export const AddOrgMemberModal = ({ getOptionLabel={(option) => option.name} value={value} onChange={onChange} + components={{ Option: RoleOption }} /> )} diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 06de07e76..2798a7121 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -36,7 +36,8 @@ type GithubFormData = BaseFormData & type GithubRadarFormData = BaseFormData & Pick; -type GitLabFormData = BaseFormData & Pick; +type GitLabFormData = BaseFormData & + Pick; type AzureKeyVaultFormData = BaseFormData & Pick & @@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => { clearState(AppConnection.GitLab); - const { connectionId, name, description, returnUrl, isUpdate } = formData; + const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData; try { if (isUpdate && connectionId) { @@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => { app: AppConnection.GitLab, connectionId, credentials: { - code: code as string + code: code as string, + instanceUrl: credentials.instanceUrl as string } }); } else { @@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => { description, method: GitLabConnectionMethod.OAuth, credentials: { - code: code as string + code: code as string, + instanceUrl: credentials.instanceUrl as string } }); } diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx index dcfc6a3f0..e938aed7d 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsSection.tsx @@ -40,12 +40,20 @@ export const LogsSection = withPermission( }); const [timezone, setTimezone] = useState(Timezone.Local); - const [dateFilter, setDateFilter] = useState({ - startDate: new Date(Number(new Date()) - ms("1h")), - endDate: new Date(), - type: AuditLogDateFilterType.Relative, - relativeModeValue: "1h" - }); + const [dateFilter, setDateFilter] = useState( + presets?.endDate || presets?.startDate + ? { + type: AuditLogDateFilterType.Absolute, + startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")), + endDate: presets?.endDate || new Date() + } + : { + startDate: new Date(Number(new Date()) - ms("1h")), + endDate: new Date(), + type: AuditLogDateFilterType.Relative, + relativeModeValue: "1h" + } + ); useEffect(() => { if (subscription && !subscription.auditLogs) { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index ac8685192..53df91974 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -1,5 +1,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -14,7 +15,21 @@ import { import { useLogoutUser, useUpdateOrg } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -export const OrgGeneralAuthSection = () => { +enum EnforceAuthType { + SAML = "saml", + GOOGLE = "google", + OIDC = "oidc" +} + +export const OrgGeneralAuthSection = ({ + isSamlConfigured, + isOidcConfigured, + isGoogleConfigured +}: { + isSamlConfigured: boolean; + isOidcConfigured: boolean; + isGoogleConfigured: boolean; +}) => { const { currentOrg } = useOrganization(); const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); @@ -23,27 +38,61 @@ export const OrgGeneralAuthSection = () => { const logout = useLogoutUser(); - const handleEnforceOrgAuthToggle = async (value: boolean) => { + const handleEnforceOrgAuthToggle = async (value: boolean, type: EnforceAuthType) => { try { if (!currentOrg?.id) return; - if (!subscription?.samlSSO) { - handlePopUpOpen("upgradePlan"); - return; + + if (type === EnforceAuthType.SAML) { + if (!subscription?.samlSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else if (type === EnforceAuthType.GOOGLE) { + if (!subscription?.enforceGoogleSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + googleSsoAuthEnforced: value + }); + } else if (type === EnforceAuthType.OIDC) { + if (!subscription?.oidcSSO) { + handlePopUpOpen("upgradePlan"); + return; + } + + await mutateAsync({ + orgId: currentOrg?.id, + authEnforced: value + }); + } else { + createNotification({ + text: `Invalid auth enforcement type ${type}`, + type: "error" + }); } - await mutateAsync({ - orgId: currentOrg?.id, - authEnforced: value - }); - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, + text: `Successfully ${value ? "enabled" : "disabled"} org-level auth`, type: "success" }); if (value) { await logout.mutateAsync(); - window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + + if (type === EnforceAuthType.SAML) { + window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`); + } else if (type === EnforceAuthType.GOOGLE) { + window.open(`/api/v1/sso/redirect/google?org_slug=${currentOrg.slug}`); + } + window.close(); } } catch (err) { @@ -78,45 +127,91 @@ export const OrgGeneralAuthSection = () => { }; return ( - <> - {/*
-
-

Allow users to send invites

- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

Allow members to invite new users to this organization

-
*/} -
-
-
- Enforce SAML SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isChecked={currentOrg?.authEnforced ?? false} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via SAML to access this organization +

+
+

SSO Enforcement

+

+ Manage strict enforcement of specific authentication methods for your organization.

- {currentOrg?.authEnforced && ( -
+
+
+
+
+ Enforce SAML SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.SAML) + } + isChecked={currentOrg?.authEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.googleSsoAuthEnforced} + /> + )} + +
+

+ Enforce users to authenticate via SAML to access this organization. +
+ When this is enabled your organization members will only be able to login with SAML. +

+
+ +
+
+
+ Enforce OIDC SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.OIDC) + } + isDisabled={!isAllowed} + /> + )} + +
+

+ Enforce users to authenticate via OIDC to access this organization. +
+ When this is enabled your organization members will only be able to login with OIDC. +

+
+ +
+
+
+ Enforce Google SSO +
+ + {(isAllowed) => ( + + handleEnforceOrgAuthToggle(value, EnforceAuthType.GOOGLE) + } + isChecked={currentOrg?.googleSsoAuthEnforced ?? false} + isDisabled={!isAllowed || currentOrg?.authEnforced} + /> + )} + +
+

+ Enforce users to authenticate via Google to access this organization. +
+ When this is enabled your organization members will only be able to login with Google. +

+
+
+ {(currentOrg?.authEnforced || currentOrg?.googleSsoAuthEnforced) && ( +
Enable Admin SSO Bypass @@ -125,8 +220,8 @@ export const OrgGeneralAuthSection = () => { content={
- When this is enabled, we strongly recommend enforcing MFA at the organization - level. + When enabling admin SSO bypass, we highly recommend enabling MFA enforcement + at the organization-level for security reasons.

In case of a lockout, admins can use the{" "} @@ -182,6 +277,6 @@ export const OrgGeneralAuthSection = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can enforce SAML SSO if you switch to Infisical's Pro plan." /> - +

); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index 9c7293210..e66987bae 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -95,43 +95,25 @@ export const OrgLDAPSection = (): JSX.Element => { }; return ( -
+
-
-

LDAP

-
- - {(isAllowed) => ( - - )} - +
+
+

LDAP

+

Manage LDAP authentication configuration

-
-

Manage LDAP authentication configuration

-
-
-
-

LDAP Group Mappings

{(isAllowed) => ( - )}
-

- Manage how LDAP groups are mapped to internal groups in Infisical -

+ {data && ( -
+

Enable LDAP

@@ -152,6 +134,27 @@ export const OrgLDAPSection = (): JSX.Element => {

)} + +
+
+

LDAP Group Mappings

+ + {(isAllowed) => ( + + )} + +
+

+ Manage how LDAP groups are mapped to internal groups in Infisical +

+
+ { const { data, isPending } = useGetOIDCConfig(currentOrg?.id ?? ""); const { mutateAsync } = useUpdateOIDCConfig(); - const { mutateAsync: updateOrg } = useUpdateOrg(); - const logout = useLogoutUser(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addOIDC", "upgradePlan" @@ -54,56 +52,6 @@ export const OrgOIDCSection = (): JSX.Element => { } }; - const handleEnforceOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - authEnforced: value - }); - - createNotification({ - text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`, - type: "success" - }); - - if (value) { - await logout.mutateAsync(); - window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`); - window.close(); - } - } catch (err) { - console.error(err); - } - }; - - const handleEnableBypassOrgAuthToggle = async (value: boolean) => { - try { - if (!currentOrg?.id) return; - if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan"); - return; - } - - await updateOrg({ - orgId: currentOrg?.id, - bypassOrgAuthEnabled: value - }); - - createNotification({ - text: `Successfully ${value ? "enabled" : "disabled"} admin bypassing of org-level auth`, - type: "success" - }); - } catch (err) { - console.error(err); - } - }; - const handleOIDCGroupManagement = async (value: boolean) => { try { if (!currentOrg?.id) return; @@ -136,25 +84,22 @@ export const OrgOIDCSection = (): JSX.Element => { }; return ( -
-
-
-

OIDC

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

OIDC

+

Manage OIDC authentication configuration

-

Manage OIDC authentication configuration

+ + {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
{data && (
@@ -178,88 +123,6 @@ export const OrgOIDCSection = (): JSX.Element => {

)} -
-
-
- Enforce OIDC SSO -
- - {(isAllowed) => ( - handleEnforceOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- Enforce users to authenticate via OIDC to access this organization. -

-
- {currentOrg?.authEnforced && ( -
-
-
- Enable Admin SSO Bypass - - - When this is enabled, we strongly recommend enforcing MFA at the organization - level. - -

- In case of a lockout, admins can use the{" "} - - Admin Login Portal - {" "} - at{" "} - - {window.location.origin}/login/admin - -

-
- } - > - - -
- - {(isAllowed) => ( - handleEnableBypassOrgAuthToggle(value)} - isDisabled={!isAllowed} - /> - )} - -
-

- - Allow organization admins to bypass OIDC enforcement when SSO is unavailable, - misconfigured, or inaccessible. - -

-
- )}
diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 3ffd8b3c2..33843f50f 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -79,25 +79,24 @@ export const OrgSSOSection = (): JSX.Element => { }; return ( - <> -
-
-
-

SAML

- {!isPending && ( - - {(isAllowed) => ( - - )} - - )} +
+
+
+

SAML

+

Manage SAML authentication configuration

-

Manage SAML authentication configuration

+ {!isPending && ( + + {(isAllowed) => ( + + )} + + )}
-
-
+
+

Enable SAML

{!isPending && ( @@ -126,6 +125,6 @@ export const OrgSSOSection = (): JSX.Element => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="You can use SAML SSO if you switch to Infisical's Pro plan." /> - +
); }; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 9b11c1302..9964fdf4d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -49,13 +49,19 @@ export const OrgSsoTab = withPermission( ); const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig; - const shouldDisplaySection = (method: LoginMethod) => - !enabledLoginMethods || enabledLoginMethods.includes(method); + const shouldDisplaySection = (method: LoginMethod[] | LoginMethod) => { + if (Array.isArray(method)) { + return method.some((m) => !enabledLoginMethods || enabledLoginMethods.includes(m)); + } - const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer); + return !enabledLoginMethods || enabledLoginMethods.includes(method); + }; + + const isOidcConfigured = Boolean(oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer)); const isSamlConfigured = samlConfig && (samlConfig.entryPoint || samlConfig.issuer || samlConfig.cert); const isLdapConfigured = ldapConfig && ldapConfig.url; + const isGoogleConfigured = shouldDisplaySection(LoginMethod.GOOGLE); const shouldShowCreateIdentityProviderView = !isOidcConfigured && !isSamlConfigured && !isLdapConfigured; @@ -65,11 +71,14 @@ export const OrgSsoTab = withPermission( shouldDisplaySection(LoginMethod.OIDC) || shouldDisplaySection(LoginMethod.LDAP) ? ( <> -
-

Connect an Identity Provider

-

- Connect your identity provider to simplify user management -

+
+
+

Connect an Identity Provider

+

+ Connect your identity provider to simplify user management with options like SAML, + OIDC, and LDAP. +

+
{shouldDisplaySection(LoginMethod.SAML) && (
- {shouldShowCreateIdentityProviderView ? ( - createIdentityProviderView - ) : ( - <> - {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && ( -
- - +
+ {shouldDisplaySection([LoginMethod.SAML, LoginMethod.GOOGLE]) && ( + + )} + + {shouldShowCreateIdentityProviderView ? ( + createIdentityProviderView + ) : ( +
+
+ {isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && } + {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } + {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && }
- )} - {isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && } - {isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && } - - )} +
+ )} +
handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index 811bf57e7..d9c9a8718 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -6,6 +6,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import { RoleOption } from "@app/components/roles"; import { Alert, AlertDescription, @@ -320,6 +321,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { > + p.environments.find((e) => e.slug === selectedEnvironment) && p.secretPath === secretPath + ); + + if ( + policy?.maxTimePeriod && + (!data.temporaryAccess.isTemporary || + ms(data.temporaryAccess.temporaryRange) > ms(policy.maxTimePeriod)) + ) { + createNotification({ + type: "error", + text: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`, + title: "Error" + }); + return; + } + const actions = [ { action: ProjectPermissionActions.Read, allowed: data.read }, { action: ProjectPermissionActions.Create, allowed: data.create }, diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 602ce457e..9ebc903a5 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -21,6 +21,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({ [ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional() }); +const SecretEventsPolicyActionSchema = z.object({ + [ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(), + [ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional() +}); + const SecretRollbackPolicyActionSchema = z.object({ read: z.boolean().optional(), create: z.boolean().optional() @@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.SecretScanningFindings]: SecretScanningFindingPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretScanningConfigs]: - SecretScanningConfigPolicyActionSchema.array().default([]) + SecretScanningConfigPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({ + conditions: ConditionSchema + }) + .array() + .default([]) }) .partial() .optional() @@ -374,7 +387,8 @@ type TConditionalFields = | ProjectPermissionSub.SshHosts | ProjectPermissionSub.SecretRotation | ProjectPermissionSub.Identity - | ProjectPermissionSub.SecretSyncs; + | ProjectPermissionSub.SecretSyncs + | ProjectPermissionSub.SecretEvents; export const isConditionalSubjects = ( subject: ProjectPermissionSub @@ -388,7 +402,8 @@ export const isConditionalSubjects = ( subject === ProjectPermissionSub.SecretRotation || subject === ProjectPermissionSub.PkiSubscribers || subject === ProjectPermissionSub.CertificateTemplates || - subject === ProjectPermissionSub.SecretSyncs; + subject === ProjectPermissionSub.SecretSyncs || + subject === ProjectPermissionSub.SecretEvents; const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const formConditions: z.infer = []; @@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshHostGroups, - ProjectPermissionSub.SecretSyncs + ProjectPermissionSub.SecretSyncs, + ProjectPermissionSub.SecretEvents ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.SecretEvents) { + const canSubscribeCreate = action.includes( + ProjectPermissionSecretEventActions.SubscribeCreated + ); + const canSubscribeUpdate = action.includes( + ProjectPermissionSecretEventActions.SubscribeUpdated + ); + const canSubscribeDelete = action.includes( + ProjectPermissionSecretEventActions.SubscribeDeleted + ); + const canSubscribeImportMutations = action.includes( + ProjectPermissionSecretEventActions.SubscribeImportMutations + ); + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + "subscribe-on-created": canSubscribeCreate, + "subscribe-on-deleted": canSubscribeDelete, + "subscribe-on-updated": canSubscribeUpdate, + "subscribe-on-import-mutations": canSubscribeImportMutations, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [] + }); + + return; + } + // for other subjects const canRead = action.includes(ProjectPermissionActions.Read); const canEdit = action.includes(ProjectPermissionActions.Edit); @@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Read Value", value: ProjectPermissionSecretActions.ReadValue }, { label: "Modify", value: ProjectPermissionSecretActions.Edit }, { label: "Remove", value: ProjectPermissionSecretActions.Delete }, - { label: "Create", value: ProjectPermissionSecretActions.Create }, - { label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe } + { label: "Create", value: ProjectPermissionSecretActions.Create } ] }, [ProjectPermissionSub.SecretFolders]: { @@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { value: ProjectPermissionSecretScanningConfigActions.Update } ] + }, + [ProjectPermissionSub.SecretEvents]: { + title: "Secret Events", + actions: [ + { + label: "Subscribe on Created", + value: ProjectPermissionSecretEventActions.SubscribeCreated + }, + { + label: "Subscribe on Deleted", + value: ProjectPermissionSecretEventActions.SubscribeDeleted + }, + { + label: "Subscribe on Updated", + value: ProjectPermissionSecretEventActions.SubscribeUpdated + }, + { + label: "Subscribe on Import Mutations", + value: ProjectPermissionSecretEventActions.SubscribeImportMutations + } + ] } }; @@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({ [ProjectPermissionSub.SecretRollback]: enabled, [ProjectPermissionSub.SecretRotation]: enabled, [ProjectPermissionSub.ServiceTokens]: enabled, - [ProjectPermissionSub.Commits]: enabled + [ProjectPermissionSub.Commits]: enabled, + [ProjectPermissionSub.SecretEvents]: enabled }); const KmsPermissionSubjects = (enabled = false) => ({ diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx index 4dbf84d92..f50446a8e 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection.tsx @@ -32,6 +32,7 @@ import { rolePermission2Form, TFormSchema } from "./ProjectRoleModifySection.utils"; +import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions"; import { SecretPermissionConditions } from "./SecretPermissionConditions"; import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions"; import { SshHostPermissionConditions } from "./SshHostPermissionConditions"; @@ -72,6 +73,10 @@ export const renderConditionalComponents = ( return ; } + if (subject === ProjectPermissionSub.SecretEvents) { + return ; + } + return ; } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx new file mode 100644 index 000000000..801375f0c --- /dev/null +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretEventPermissionConditions.tsx @@ -0,0 +1,22 @@ +import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; + +import { ConditionsFields } from "./ConditionsFields"; + +type Props = { + position?: number; + isDisabled?: boolean; +}; + +export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => { + return ( + + ); +}; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx index b7f846bbd..9978b3e45 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SecretPermissionConditions.tsx @@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) { value: "environment", label: "Environment Slug" }, { value: "secretPath", label: "Secret Path" }, { value: "secretName", label: "Secret Name" }, - { value: "secretTags", label: "Secret Tags" }, - { value: "eventType", label: "Event Type" } + { value: "secretTags", label: "Secret Tags" } ]} /> ); diff --git a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx index 8c0687d61..b4ecd1e0e 100644 --- a/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsDetailsByIDPage/components/IntegrationAuditLogsSection.tsx @@ -24,7 +24,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {

Integration Logs

- Displaying audit logs from the last {auditLogsRetentionDays} days + Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days

{ showFilters={false} presets={{ eventMetadata: { integrationId: integration.id }, - startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)), + startDate: new Date( + new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60)) + ), eventType: INTEGRATION_EVENTS }} /> diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx index 00fcfe264..ab717f9b0 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/RenderSyncDestinationCol.tsx @@ -1,5 +1,8 @@ -import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; -import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; +import { + useRenderConnectionListEnvironmentGroups, + useRenderConnectionListServices +} from "@app/hooks/api/appConnections/render"; +import { RenderSyncScope, TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; import { getSecretSyncDestinationColValues } from "../helpers"; import { SecretSyncTableCell } from "../SecretSyncTableCell"; @@ -9,21 +12,59 @@ type Props = { }; export const RenderSyncDestinationCol = ({ secretSync }: Props) => { + const isServiceScope = secretSync.destinationConfig.scope === RenderSyncScope.Service; + const { data: services = [], isPending } = useRenderConnectionListServices( - secretSync.connectionId + secretSync.connectionId, + { + enabled: isServiceScope + } ); - const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ - ...secretSync, - destinationConfig: { - ...secretSync.destinationConfig, - serviceName: services.find((s) => s.id === secretSync.destinationConfig.serviceId)?.name + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(secretSync.connectionId, { enabled: !isServiceScope }); + + switch (secretSync.destinationConfig.scope) { + case RenderSyncScope.Service: { + const id = secretSync.destinationConfig.serviceId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + serviceName: services.find((s) => s.id === id)?.name + } + }); + + if (isPending) { + return ( + + ); + } + + return ; } - }); + case RenderSyncScope.EnvironmentGroup: { + const id = secretSync.destinationConfig.environmentGroupId; + const { primaryText, secondaryText } = getSecretSyncDestinationColValues({ + ...secretSync, + destinationConfig: { + ...secretSync.destinationConfig, + environmentGroupName: groups.find((s) => s.id === id)?.name + } + }); - if (isPending) { - return ; + if (isGroupsPending) { + return ( + + ); + } + + return ; + } + default: + throw new Error("Unknown render sync destination scope"); } - - return ; }; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 9b88ba447..01c4024d7 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -8,6 +8,7 @@ import { } from "@app/hooks/api/secretSyncs/types/github-sync"; import { GitLabSyncScope } from "@app/hooks/api/secretSyncs/types/gitlab-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; // This functional ensures parity across what is displayed in the destination column // and the values used when search filtering @@ -125,8 +126,15 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { secondaryText = destinationConfig.app; break; case SecretSync.Render: - primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; - secondaryText = "Service"; + if (destinationConfig.scope === RenderSyncScope.Service) { + primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; + secondaryText = "Service"; + } else { + primaryText = + destinationConfig.environmentGroupName ?? destinationConfig.environmentGroupId; + secondaryText = "Environment Group"; + } + break; case SecretSync.Flyio: primaryText = destinationConfig.appId; diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx index f8023f705..a969edea8 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -23,8 +24,16 @@ export const SecretOverviewFolderRow = ({ onToggleFolderEdit, onClick }: Props) => { + const [isClicking, setIsClicking] = useState(false); + const handleClick = () => { + if (isClicking) return; + + setIsClicking(true); + onClick(folderName); + setTimeout(() => setIsClicking(false), 1000); + }; return ( - onClick(folderName)}> +
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx index 26a64f8a5..11ee3def8 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, + useSubscription, useWorkspace } from "@app/context"; import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; @@ -51,6 +52,7 @@ export const SelectionPanel = ({ usedBySecretSyncs = [] }: Props) => { const { permission } = useProjectPermission(); + const { subscription } = useSubscription(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "bulkDeleteEntries", @@ -101,6 +103,16 @@ export const SelectionPanel = ({ return "Do you want to delete the selected folders across environments?"; }; + const getDeleteModalSubTitle = () => { + if (selectedFolderCount > 0) { + if (subscription?.pitRecovery) { + return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit."; + } + return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature."; + } + return undefined; + }; + const handleBulkDelete = async () => { let processedEntries = 0; @@ -279,6 +291,7 @@ export const SelectionPanel = ({ isOpen={popUp.bulkDeleteEntries.isOpen} deleteKey="delete" title={getDeleteModalTitle()} + subTitle={getDeleteModalSubTitle()} onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)} onDeleteApproved={handleBulkDelete} formContent={ diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index a8cb199d4..957091fe5 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -592,6 +592,16 @@ export const AccessApprovalRequest = ({ setSelectedRequest(null); refetchRequests(); }} + onUpdate={(request) => { + // scott: this isn't ideal but our current use of state makes this complicated... + // we shouldn't be using state like this... + handleSelectRequest({ + ...selectedRequest, + isTemporary: request.isTemporary, + temporaryRange: request.temporaryRange, + reviewers: [] + }); + }} canBypass={generateRequestDetails(selectedRequest).canBypass} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx new file mode 100644 index 000000000..c9c825a3c --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/EditAccessRequestModal.tsx @@ -0,0 +1,185 @@ +import { Controller, useForm } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + TextArea +} from "@app/components/v2"; +import { useUpdateAccessRequest } from "@app/hooks/api/accessApproval/mutation"; +import { accessApprovalKeys } from "@app/hooks/api/accessApproval/queries"; +import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; + +type ContentProps = { + accessRequest: TAccessApprovalRequest; + onComplete: (request: TAccessApprovalRequest) => void; + projectSlug: string; +}; + +const EditSchema = z.object({ + temporaryRange: z + .string() + .nonempty("Required") + .transform((val, ctx) => { + const parsedMs = ms(val); + + if (typeof parsedMs !== "number" || parsedMs <= 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')." + }); + return z.NEVER; + } + return val; + }), + editNote: z.string().nonempty("Required") +}); + +type FormData = z.infer; + +const Content = ({ accessRequest, onComplete, projectSlug }: ContentProps) => { + const update = useUpdateAccessRequest(); + const queryClient = useQueryClient(); + const { + handleSubmit, + control, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(EditSchema), + defaultValues: { + temporaryRange: accessRequest.temporaryRange ?? "1h", + editNote: "" + } + }); + + const onSubmit = async (form: FormData) => { + try { + const request = await update.mutateAsync({ + requestId: accessRequest.id, + projectSlug, + ...form + }); + await queryClient.refetchQueries({ + queryKey: accessApprovalKeys.getAccessApprovalPolicies(projectSlug) + }); + + createNotification({ + type: "success", + text: "Access request updated successfully." + }); + onComplete(request); + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to update access request" + }); + } + }; + + return ( +
+
+ + Updating this access request will restart the review process and require all approvers to + re-approve it. +
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + helperText={`Must be less than current access duration: ${accessRequest.isTemporary ? accessRequest.temporaryRange : "Permanent"}`} + > + + + )} + /> + ( + +